Halo 3 Reclaimer
player data extractor from Xbox 360 version of Halo 3
-copyright 2025, David Polakovic, licensed under GPLv3
+copyright 2025, 2026, David Polakovic, licensed under GPLv3
+
ABOUT:
Today it is almost impossible to get you player data
like screenshots or Forge map variants from classic
Xbox 360 Halo 3 to you PC. This program solves this
-problem.
+problem. The reclaimer currently exports:
+- player screenshots
+- Forge map variants
+
+
+
+BUILDING FORM SOURCE:
+ Even tough I am not a supporter of distributing software
+ in already built form, I do so for the comfort of target
+ audience of this specific program. So unless you want to
+ change some suff in the code, YOU CAN SKIP THIS PROCEDURE.
+
+ Otherwise, there is a build.xml file configured for your
+ (and mine) convinience which requires build tool Apache Ant.
+ https://ant.apache.org/index.html
+
+ To build the program:
+ 1. Install Ant
+ 2. Inspect the 'build.xml' in root directory
+ 3. Inspect the code in src to see what it does
+ 4. Then simply run:
+ ant all
+
HOW TO RUN:
-You need to have Java version 8 or later. For lanuching
-the Reclaimer in graphical user interface mode, simply
-double click the halo3-reclaimer.jar. If your GUI options
-are limited, you can open it via terminal in terminal user
-interface mode by typing:
- java -jar halo3-reclaimer.jar -h
-Make sure to be in same directory as the file itself.
-NOTE: Apple users might experience security warnings as
-for all .jar files. Check some tutorials how to open .jar
-files on Mac or use terminal:
- java -jar halo3-reclaimer.jar (without option)
+ You need to have Java version 8 or later. For lanuching
+ the Reclaimer in graphical user interface mode, simply
+ double click the halo3-reclaimer.jar. If your GUI options
+ are limited, you can open it via terminal in terminal user
+ interface mode by typing:
+ java -jar halo3-reclaimer.jar -h
+ Make sure to be in same directory as the file itself.
+ NOTE: Apple users might experience security warnings as
+ for all .jar files. Check some tutorials how to open .jar
+ files on Mac or use terminal:
+ java -jar halo3-reclaimer.jar (without option)
+
HOW TO USE
-You can copy your Halo 3 screenshots and maps into
-USB stick formated for Xbox 360 file system. Then,
-insert the USB stick into the computer and run the
-halo3-reclaimer.
-1. Click 'Browse' next to 'Raw file destination'
- to select your Xbox 360 Halo 3 file
-2. Click 'Browse' next to 'Save destination
- to choose where to save the converted image
-3. Click 'PELICAN!' to start the extraction process
- The tool will convert your Xbox 360 file
- into a standard JPEG image or map file* that you
- share on any device.
-* map files are not implemented yet
+ See user manual in 'Doc/user-manual.pdf'.
-BUILDING FORM SOURCE:
-There is a build.xml file configured for you convinience
-which requires Apache Ant. Install Ant and simply run:
- ant all
+LICENSE:
+ Halo 3 Reclaimer is intelectual property of David Polakovic
+ and is licensed under GNU General Public License version 3.
+ See full license in LICENSE.txt or on web: https://www.gnu.org/licenses/gpl-3.0.en.html
+
+ Documentation for the program is is intelectual property of
+ David Polakovic and is licensed under GNU Free Documentation License version 1.3
+ See full license in DOC_LICENSE.txt or on web: https://www.gnu.org/licenses/fdl-1.3.html
-LICENSE:
-https://www.gnu.org/licenses/gpl-3.0.en.html
-see LICENSE.txt
CONTACT:
-email@dpolakovic.space
-Fetch my GPG here: https://dpolakovic.space/gpg
+ email@dpolakovic.space
+ Fetch my GPG here: https://dpolakovic.space/gpg
+
+
RELEASE NOTES:
-1.0 - Screenshots extracting
+ 0.1 - Screenshots extracting
+ 0.2 - Forge maps extracting
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Arbiter (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
catch(err) {
}
//-->
-var methods = {"i0":9,"i1":9};
+var methods = {"i0":9};
var tabs = {65535:["t0","All Methods"],1:["t1","Static Methods"],8:["t4","Concrete Methods"]};
var altColor = "altColor";
var rowColor = "rowColor";
<div class="subNav">
<ul class="navList">
<li>Prev Class</li>
-<li><a href="ErrorHandler.html" title="class in <Unnamed>"><span class="typeNameLink">Next Class</span></a></li>
+<li><a href="Cortana.html" title="class in <Unnamed>"><span class="typeNameLink">Next Class</span></a></li>
</ul>
<ul class="navList">
<li><a href="index.html?Arbiter.html" target="_top">Frames</a></li>
<ul class="subNavList">
<li>Summary: </li>
<li>Nested | </li>
-<li>Field | </li>
+<li><a href="#field.summary">Field</a> | </li>
<li><a href="#constructor.summary">Constr</a> | </li>
<li><a href="#method.summary">Method</a></li>
</ul>
<ul class="subNavList">
<li>Detail: </li>
-<li>Field | </li>
+<li><a href="#field.detail">Field</a> | </li>
<li><a href="#constructor.detail">Constr</a> | </li>
<li><a href="#method.detail">Method</a></li>
</ul>
<pre>public class <span class="typeNameLabel">Arbiter</span>
extends java.lang.Object</pre>
<div class="block">Arbiter class that determines file type and routes to appropriate extractor.
-
- <p>This class analyzes the input file to determine whether it's a screenshot
- or map file, then calls the appropriate reclaimer. The Xbox 360 saves file
- type directly in file name. So it just checks for names...</p></div>
+
+ <p>The input is a single file, a whole folder, or a USB stick set up by the
+ Xbox 360. A stick (or its Xbox360 folder, or one of its Data files) goes to
+ Cortana first, which copies the Halo 3 files out of the Xbox file system;
+ other files on the stick are ignored then.</p>
+
+ <p>Halo 3 keeps every screenshot and every Forge map in its own package,
+ so the Arbiter looks inside each file: a package holding
+ <code>screen.shot</code> is a screenshot, one holding <code>sandbox.map</code>
+ is a map. Screenshots are checked first, then maps. Anything else is skipped.
+ Only when the content says nothing (DDS, TGA, raw textures) the file name
+ decides, like in version 1.0.</p>
+
+ <p>Results go to two folders in the output directory:
+ <code>screenshots-reclaimed</code> and <code>maps-reclaimed</code>.</p></div>
<dl>
<dt><span class="simpleTagLabel">Version:</span></dt>
<dd>1.0</dd>
<div class="summary">
<ul class="blockList">
<li class="blockList">
+<!-- =========== FIELD SUMMARY =========== -->
+<ul class="blockList">
+<li class="blockList"><a name="field.summary">
+<!-- -->
+</a>
+<h3>Field Summary</h3>
+<table class="memberSummary" border="0" cellpadding="3" cellspacing="0" summary="Field Summary table, listing fields, and an explanation">
+<caption><span>Fields</span><span class="tabEnd"> </span></caption>
+<tr>
+<th class="colFirst" scope="col">Modifier and Type</th>
+<th class="colLast" scope="col">Field and Description</th>
+</tr>
+<tr class="altColor">
+<td class="colFirst"><code>static java.lang.String</code></td>
+<td class="colLast"><code><span class="memberNameLink"><a href="Arbiter.html#MAPS_DIR">MAPS_DIR</a></span></code> </td>
+</tr>
+<tr class="rowColor">
+<td class="colFirst"><code>static java.lang.String</code></td>
+<td class="colLast"><code><span class="memberNameLink"><a href="Arbiter.html#SCREENSHOTS_DIR">SCREENSHOTS_DIR</a></span></code> </td>
+</tr>
+</table>
+</li>
+</ul>
<!-- ======== CONSTRUCTOR SUMMARY ======== -->
<ul class="blockList">
<li class="blockList"><a name="constructor.summary">
</tr>
<tr id="i0" class="altColor">
<td class="colFirst"><code>static java.lang.String</code></td>
-<td class="colLast"><code><span class="memberNameLink"><a href="Arbiter.html#getFileTypeDescription-java.io.File-">getFileTypeDescription</a></span>(java.io.File inputFile)</code> </td>
-</tr>
-<tr id="i1" class="rowColor">
-<td class="colFirst"><code>static void</code></td>
-<td class="colLast"><code><span class="memberNameLink"><a href="Arbiter.html#processFile-java.io.File-java.io.File-">processFile</a></span>(java.io.File inputFile,
- java.io.File outputDir)</code> </td>
+<td class="colLast"><code><span class="memberNameLink"><a href="Arbiter.html#processFile-java.io.File-java.io.File-">processFile</a></span>(java.io.File input,
+ java.io.File outputDir)</code>
+<div class="block">Reclaims all screenshots and maps found in a file or folder.</div>
+</td>
</tr>
</table>
<ul class="blockList">
<div class="details">
<ul class="blockList">
<li class="blockList">
+<!-- ============ FIELD DETAIL =========== -->
+<ul class="blockList">
+<li class="blockList"><a name="field.detail">
+<!-- -->
+</a>
+<h3>Field Detail</h3>
+<a name="SCREENSHOTS_DIR">
+<!-- -->
+</a>
+<ul class="blockList">
+<li class="blockList">
+<h4>SCREENSHOTS_DIR</h4>
+<pre>public static final java.lang.String SCREENSHOTS_DIR</pre>
+<dl>
+<dt><span class="seeLabel">See Also:</span></dt>
+<dd><a href="constant-values.html#Arbiter.SCREENSHOTS_DIR">Constant Field Values</a></dd>
+</dl>
+</li>
+</ul>
+<a name="MAPS_DIR">
+<!-- -->
+</a>
+<ul class="blockListLast">
+<li class="blockList">
+<h4>MAPS_DIR</h4>
+<pre>public static final java.lang.String MAPS_DIR</pre>
+<dl>
+<dt><span class="seeLabel">See Also:</span></dt>
+<dd><a href="constant-values.html#Arbiter.MAPS_DIR">Constant Field Values</a></dd>
+</dl>
+</li>
+</ul>
+</li>
+</ul>
<!-- ========= CONSTRUCTOR DETAIL ======== -->
<ul class="blockList">
<li class="blockList"><a name="constructor.detail">
<a name="processFile-java.io.File-java.io.File-">
<!-- -->
</a>
-<ul class="blockList">
+<ul class="blockListLast">
<li class="blockList">
<h4>processFile</h4>
-<pre>public static void processFile(java.io.File inputFile,
- java.io.File outputDir)
- throws java.lang.Exception</pre>
+<pre>public static java.lang.String processFile(java.io.File input,
+ java.io.File outputDir)
+ throws java.lang.Exception</pre>
+<div class="block">Reclaims all screenshots and maps found in a file or folder.</div>
<dl>
+<dt><span class="returnLabel">Returns:</span></dt>
+<dd>what was reclaimed, for example "Reclaimed 2 pictures and 1 map"</dd>
<dt><span class="throwsLabel">Throws:</span></dt>
-<dd><code>java.lang.Exception</code></dd>
+<dd><code>java.lang.Exception</code> - if nothing could be reclaimed</dd>
</dl>
</li>
</ul>
-<a name="getFileTypeDescription-java.io.File-">
-<!-- -->
-</a>
-<ul class="blockListLast">
-<li class="blockList">
-<h4>getFileTypeDescription</h4>
-<pre>public static java.lang.String getFileTypeDescription(java.io.File inputFile)</pre>
-</li>
-</ul>
</li>
</ul>
</li>
<div class="subNav">
<ul class="navList">
<li>Prev Class</li>
-<li><a href="ErrorHandler.html" title="class in <Unnamed>"><span class="typeNameLink">Next Class</span></a></li>
+<li><a href="Cortana.html" title="class in <Unnamed>"><span class="typeNameLink">Next Class</span></a></li>
</ul>
<ul class="navList">
<li><a href="index.html?Arbiter.html" target="_top">Frames</a></li>
<ul class="subNavList">
<li>Summary: </li>
<li>Nested | </li>
-<li>Field | </li>
+<li><a href="#field.summary">Field</a> | </li>
<li><a href="#constructor.summary">Constr</a> | </li>
<li><a href="#method.summary">Method</a></li>
</ul>
<ul class="subNavList">
<li>Detail: </li>
-<li>Field | </li>
+<li><a href="#field.detail">Field</a> | </li>
<li><a href="#constructor.detail">Constr</a> | </li>
<li><a href="#method.detail">Method</a></li>
</ul>
--- /dev/null
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
+<!-- NewPage -->
+<html lang="en">
+<head>
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
+<title>Cortana (Halo 3 Reclaimer)</title>
+<meta name="date" content="2026-11-02">
+<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
+<script type="text/javascript" src="script.js"></script>
+</head>
+<body>
+<script type="text/javascript"><!--
+ try {
+ if (location.href.indexOf('is-external=true') == -1) {
+ parent.document.title="Cortana (Halo 3 Reclaimer)";
+ }
+ }
+ catch(err) {
+ }
+//-->
+var methods = {"i0":9,"i1":9,"i2":9};
+var tabs = {65535:["t0","All Methods"],1:["t1","Static Methods"],8:["t4","Concrete Methods"]};
+var altColor = "altColor";
+var rowColor = "rowColor";
+var tableTab = "tableTab";
+var activeTableTab = "activeTableTab";
+</script>
+<noscript>
+<div>JavaScript is disabled on your browser.</div>
+</noscript>
+<!-- ========= START OF TOP NAVBAR ======= -->
+<div class="topNav"><a name="navbar.top">
+<!-- -->
+</a>
+<div class="skipNav"><a href="#skip.navbar.top" title="Skip navigation links">Skip navigation links</a></div>
+<a name="navbar.top.firstrow">
+<!-- -->
+</a>
+<ul class="navList" title="Navigation">
+<li><a href="overview-summary.html">Overview</a></li>
+<li><a href="package-summary.html">Package</a></li>
+<li class="navBarCell1Rev">Class</li>
+<li><a href="class-use/Cortana.html">Use</a></li>
+<li><a href="package-tree.html">Tree</a></li>
+<li><a href="deprecated-list.html">Deprecated</a></li>
+<li><a href="index-all.html">Index</a></li>
+<li><a href="help-doc.html">Help</a></li>
+</ul>
+<div class="aboutLanguage"><b>Halo 3 Reclaimer</b></div>
+</div>
+<div class="subNav">
+<ul class="navList">
+<li><a href="Arbiter.html" title="class in <Unnamed>"><span class="typeNameLink">Prev Class</span></a></li>
+<li><a href="ErrorHandler.html" title="class in <Unnamed>"><span class="typeNameLink">Next Class</span></a></li>
+</ul>
+<ul class="navList">
+<li><a href="index.html?Cortana.html" target="_top">Frames</a></li>
+<li><a href="Cortana.html" target="_top">No Frames</a></li>
+</ul>
+<ul class="navList" id="allclasses_navbar_top">
+<li><a href="allclasses-noframe.html">All Classes</a></li>
+</ul>
+<div>
+<script type="text/javascript"><!--
+ allClassesLink = document.getElementById("allclasses_navbar_top");
+ if(window==top) {
+ allClassesLink.style.display = "block";
+ }
+ else {
+ allClassesLink.style.display = "none";
+ }
+ //-->
+</script>
+</div>
+<div>
+<ul class="subNavList">
+<li>Summary: </li>
+<li>Nested | </li>
+<li>Field | </li>
+<li><a href="#constructor.summary">Constr</a> | </li>
+<li><a href="#method.summary">Method</a></li>
+</ul>
+<ul class="subNavList">
+<li>Detail: </li>
+<li>Field | </li>
+<li><a href="#constructor.detail">Constr</a> | </li>
+<li><a href="#method.detail">Method</a></li>
+</ul>
+</div>
+<a name="skip.navbar.top">
+<!-- -->
+</a></div>
+<!-- ========= END OF TOP NAVBAR ========= -->
+<!-- ======== START OF CLASS DATA ======== -->
+<div class="header">
+<h2 title="Class Cortana" class="title">Class Cortana</h2>
+</div>
+<div class="contentContainer">
+<ul class="inheritance">
+<li>java.lang.Object</li>
+<li>
+<ul class="inheritance">
+<li>Cortana</li>
+</ul>
+</li>
+</ul>
+<div class="description">
+<ul class="blockList">
+<li class="blockList">
+<hr>
+<br>
+<pre>public class <span class="typeNameLabel">Cortana</span>
+extends java.lang.Object</pre>
+<div class="block">Reader for USB sticks set up by the Xbox 360.
+
+ <p>The console doesn't store saves on the stick as normal files. It creates
+ a hidden folder <code>Xbox360</code> with big files <code>Data0000</code>,
+ <code>Data0001</code>, <code>Data0002</code>... Joined together they are one
+ virtual drive in the Xbox's own FATX file system. Cortana opens that drive,
+ walks to the Halo 3 folders (<code>Content\<profile>\4D5307E6\...</code>)
+ and copies their files into a temporary folder, where the Arbiter sorts them
+ out like any other files. Nothing on the stick is ever changed.</p>
+
+ <p>Only Cortana can understand these needlesly complicated Michaelsoft file
+ systems.</p>
+
+ <p>Partition offsets and the FATX layout follow Free60 and Velocity
+ (hetelek/Velocity, GPLv3).</p></div>
+<dl>
+<dt><span class="simpleTagLabel">Version:</span></dt>
+<dd>1.0</dd>
+</dl>
+</li>
+</ul>
+</div>
+<div class="summary">
+<ul class="blockList">
+<li class="blockList">
+<!-- ======== CONSTRUCTOR SUMMARY ======== -->
+<ul class="blockList">
+<li class="blockList"><a name="constructor.summary">
+<!-- -->
+</a>
+<h3>Constructor Summary</h3>
+<table class="memberSummary" border="0" cellpadding="3" cellspacing="0" summary="Constructor Summary table, listing constructors, and an explanation">
+<caption><span>Constructors</span><span class="tabEnd"> </span></caption>
+<tr>
+<th class="colOne" scope="col">Constructor and Description</th>
+</tr>
+<tr class="altColor">
+<td class="colOne"><code><span class="memberNameLink"><a href="Cortana.html#Cortana--">Cortana</a></span>()</code> </td>
+</tr>
+</table>
+</li>
+</ul>
+<!-- ========== METHOD SUMMARY =========== -->
+<ul class="blockList">
+<li class="blockList"><a name="method.summary">
+<!-- -->
+</a>
+<h3>Method Summary</h3>
+<table class="memberSummary" border="0" cellpadding="3" cellspacing="0" summary="Method Summary table, listing methods, and an explanation">
+<caption><span id="t0" class="activeTableTab"><span>All Methods</span><span class="tabEnd"> </span></span><span id="t1" class="tableTab"><span><a href="javascript:show(1);">Static Methods</a></span><span class="tabEnd"> </span></span><span id="t4" class="tableTab"><span><a href="javascript:show(8);">Concrete Methods</a></span><span class="tabEnd"> </span></span></caption>
+<tr>
+<th class="colFirst" scope="col">Modifier and Type</th>
+<th class="colLast" scope="col">Method and Description</th>
+</tr>
+<tr id="i0" class="altColor">
+<td class="colFirst"><code>static java.io.File</code></td>
+<td class="colLast"><code><span class="memberNameLink"><a href="Cortana.html#copyHalo3Files-java.io.File-">copyHalo3Files</a></span>(java.io.File storage)</code>
+<div class="block">Copies every Halo 3 file from the stick into a new temporary folder and
+ returns that folder.</div>
+</td>
+</tr>
+<tr id="i1" class="rowColor">
+<td class="colFirst"><code>static void</code></td>
+<td class="colLast"><code><span class="memberNameLink"><a href="Cortana.html#deleteCopies-java.io.File-">deleteCopies</a></span>(java.io.File folder)</code>
+<div class="block">Deletes the temporary folder made by copyHalo3Files().</div>
+</td>
+</tr>
+<tr id="i2" class="altColor">
+<td class="colFirst"><code>static java.io.File</code></td>
+<td class="colLast"><code><span class="memberNameLink"><a href="Cortana.html#findStorage-java.io.File-">findStorage</a></span>(java.io.File input)</code>
+<div class="block">Returns the Xbox360 folder when the input is a USB stick set up by the
+ console, its Xbox360 folder or one of its Data files.</div>
+</td>
+</tr>
+</table>
+<ul class="blockList">
+<li class="blockList"><a name="methods.inherited.from.class.java.lang.Object">
+<!-- -->
+</a>
+<h3>Methods inherited from class java.lang.Object</h3>
+<code>clone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait</code></li>
+</ul>
+</li>
+</ul>
+</li>
+</ul>
+</div>
+<div class="details">
+<ul class="blockList">
+<li class="blockList">
+<!-- ========= CONSTRUCTOR DETAIL ======== -->
+<ul class="blockList">
+<li class="blockList"><a name="constructor.detail">
+<!-- -->
+</a>
+<h3>Constructor Detail</h3>
+<a name="Cortana--">
+<!-- -->
+</a>
+<ul class="blockListLast">
+<li class="blockList">
+<h4>Cortana</h4>
+<pre>public Cortana()</pre>
+</li>
+</ul>
+</li>
+</ul>
+<!-- ============ METHOD DETAIL ========== -->
+<ul class="blockList">
+<li class="blockList"><a name="method.detail">
+<!-- -->
+</a>
+<h3>Method Detail</h3>
+<a name="findStorage-java.io.File-">
+<!-- -->
+</a>
+<ul class="blockList">
+<li class="blockList">
+<h4>findStorage</h4>
+<pre>public static java.io.File findStorage(java.io.File input)</pre>
+<div class="block">Returns the Xbox360 folder when the input is a USB stick set up by the
+ console, its Xbox360 folder or one of its Data files. Otherwise null.</div>
+</li>
+</ul>
+<a name="copyHalo3Files-java.io.File-">
+<!-- -->
+</a>
+<ul class="blockList">
+<li class="blockList">
+<h4>copyHalo3Files</h4>
+<pre>public static java.io.File copyHalo3Files(java.io.File storage)
+ throws java.lang.Exception</pre>
+<div class="block">Copies every Halo 3 file from the stick into a new temporary folder and
+ returns that folder. Remove it with deleteCopies() when done.</div>
+<dl>
+<dt><span class="throwsLabel">Throws:</span></dt>
+<dd><code>java.lang.Exception</code> - if the stick can't be read</dd>
+</dl>
+</li>
+</ul>
+<a name="deleteCopies-java.io.File-">
+<!-- -->
+</a>
+<ul class="blockListLast">
+<li class="blockList">
+<h4>deleteCopies</h4>
+<pre>public static void deleteCopies(java.io.File folder)</pre>
+<div class="block">Deletes the temporary folder made by copyHalo3Files().</div>
+</li>
+</ul>
+</li>
+</ul>
+</li>
+</ul>
+</div>
+</div>
+<!-- ========= END OF CLASS DATA ========= -->
+<!-- ======= START OF BOTTOM NAVBAR ====== -->
+<div class="bottomNav"><a name="navbar.bottom">
+<!-- -->
+</a>
+<div class="skipNav"><a href="#skip.navbar.bottom" title="Skip navigation links">Skip navigation links</a></div>
+<a name="navbar.bottom.firstrow">
+<!-- -->
+</a>
+<ul class="navList" title="Navigation">
+<li><a href="overview-summary.html">Overview</a></li>
+<li><a href="package-summary.html">Package</a></li>
+<li class="navBarCell1Rev">Class</li>
+<li><a href="class-use/Cortana.html">Use</a></li>
+<li><a href="package-tree.html">Tree</a></li>
+<li><a href="deprecated-list.html">Deprecated</a></li>
+<li><a href="index-all.html">Index</a></li>
+<li><a href="help-doc.html">Help</a></li>
+</ul>
+<div class="aboutLanguage"><i>File extractor from Xbox 360 version of Halo 3</i></div>
+</div>
+<div class="subNav">
+<ul class="navList">
+<li><a href="Arbiter.html" title="class in <Unnamed>"><span class="typeNameLink">Prev Class</span></a></li>
+<li><a href="ErrorHandler.html" title="class in <Unnamed>"><span class="typeNameLink">Next Class</span></a></li>
+</ul>
+<ul class="navList">
+<li><a href="index.html?Cortana.html" target="_top">Frames</a></li>
+<li><a href="Cortana.html" target="_top">No Frames</a></li>
+</ul>
+<ul class="navList" id="allclasses_navbar_bottom">
+<li><a href="allclasses-noframe.html">All Classes</a></li>
+</ul>
+<div>
+<script type="text/javascript"><!--
+ allClassesLink = document.getElementById("allclasses_navbar_bottom");
+ if(window==top) {
+ allClassesLink.style.display = "block";
+ }
+ else {
+ allClassesLink.style.display = "none";
+ }
+ //-->
+</script>
+</div>
+<div>
+<ul class="subNavList">
+<li>Summary: </li>
+<li>Nested | </li>
+<li>Field | </li>
+<li><a href="#constructor.summary">Constr</a> | </li>
+<li><a href="#method.summary">Method</a></li>
+</ul>
+<ul class="subNavList">
+<li>Detail: </li>
+<li>Field | </li>
+<li><a href="#constructor.detail">Constr</a> | </li>
+<li><a href="#method.detail">Method</a></li>
+</ul>
+</div>
+<a name="skip.navbar.bottom">
+<!-- -->
+</a></div>
+<!-- ======== END OF BOTTOM NAVBAR ======= -->
+<p class="legalCopy"><small>Copyright 2025. Documentation licensed under GFDL v1.3.</small></p>
+</body>
+</html>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>ErrorHandler (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
</div>
<div class="subNav">
<ul class="navList">
-<li><a href="Arbiter.html" title="class in <Unnamed>"><span class="typeNameLink">Prev Class</span></a></li>
+<li><a href="Cortana.html" title="class in <Unnamed>"><span class="typeNameLink">Prev Class</span></a></li>
<li><a href="GraphicalUI.html" title="class in <Unnamed>"><span class="typeNameLink">Next Class</span></a></li>
</ul>
<ul class="navList">
</div>
<div class="subNav">
<ul class="navList">
-<li><a href="Arbiter.html" title="class in <Unnamed>"><span class="typeNameLink">Prev Class</span></a></li>
+<li><a href="Cortana.html" title="class in <Unnamed>"><span class="typeNameLink">Prev Class</span></a></li>
<li><a href="GraphicalUI.html" title="class in <Unnamed>"><span class="typeNameLink">Next Class</span></a></li>
</ul>
<ul class="navList">
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>GraphicalUI (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
logic for the application's windowed interface</p></div>
<dl>
<dt><span class="simpleTagLabel">Version:</span></dt>
-<dd>1.0</dd>
+<dd>1.1</dd>
<dt><span class="seeLabel">See Also:</span></dt>
<dd><a href="serialized-form.html#GraphicalUI">Serialized Form</a></dd>
</dl>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Main (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>MapReclaimer (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
<pre>public class <span class="typeNameLabel">MapReclaimer</span>
extends java.lang.Object</pre>
<div class="block">Core extraction engine for Forge map variants.
-
- <p>This is harder than it looks. WIP.</p></div>
+
+ <p>Halo 3 saves every Forge map as its own Xbox 360 STFS package
+ (a CON file with a long hex name). The package holds one file called
+ <code>sandbox.map</code>. That file is the Forge map variant itself:
+ the object placements on top of one of the original maps, stored in
+ Bungie's BLF format (chunks <code>_blf</code>, <code>chdr</code>,
+ <code>mvar</code> and <code>_eof</code>).</p>
+
+ <p>Unlike screenshots, the map has no magic bytes worth scanning for,
+ so this class reads the STFS file system properly: it walks the file
+ table, follows the block chain of <code>sandbox.map</code> and checks
+ each block against the package hash tables.</p>
+
+ <p>Supported input formats:</p>
+ <ul>
+ <li>STFS container files (CON, LIVE, PIRS) holding a Halo 3 Forge map</li>
+ <li>already extracted <code>sandbox.map</code> files (BLF)</li>
+ </ul>
+
+ <p>The saved file is not converted in any way. It is byte for byte the
+ <code>sandbox.map</code> the console keeps inside the package.</p></div>
<dl>
<dt><span class="simpleTagLabel">Version:</span></dt>
-<dd>"1.0"</dd>
+<dd>1.0</dd>
</dl>
</li>
</ul>
<tr id="i0" class="altColor">
<td class="colFirst"><code>void</code></td>
<td class="colLast"><code><span class="memberNameLink"><a href="MapReclaimer.html#extractMap-java.io.File-java.io.File-">extractMap</a></span>(java.io.File inputFile,
- java.io.File outputDir)</code> </td>
+ java.io.File outputDir)</code>
+<div class="block">Extracts the Forge map from an Xbox 360 file and saves it as
+ <input name>-reclaimed.map in the output directory.</div>
+</td>
</tr>
</table>
<ul class="blockList">
<pre>public void extractMap(java.io.File inputFile,
java.io.File outputDir)
throws java.lang.Exception</pre>
+<div class="block">Extracts the Forge map from an Xbox 360 file and saves it as
+ <input name>-reclaimed.map in the output directory.</div>
<dl>
<dt><span class="throwsLabel">Throws:</span></dt>
-<dd><code>java.lang.Exception</code></dd>
+<dd><code>java.lang.Exception</code> - if the file cannot be read, is not a Halo 3 Forge map,
+ or is damaged</dd>
</dl>
</li>
</ul>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>ScreenshotReclaimer (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>TerminalUI (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:49 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>All Classes (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
<div class="indexContainer">
<ul>
<li><a href="Arbiter.html" title="class in <Unnamed>" target="classFrame">Arbiter</a></li>
+<li><a href="Cortana.html" title="class in <Unnamed>" target="classFrame">Cortana</a></li>
<li><a href="ErrorHandler.html" title="class in <Unnamed>" target="classFrame">ErrorHandler</a></li>
<li><a href="GraphicalUI.html" title="class in <Unnamed>" target="classFrame">GraphicalUI</a></li>
<li><a href="Main.html" title="class in <Unnamed>" target="classFrame">Main</a></li>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:49 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>All Classes (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
<div class="indexContainer">
<ul>
<li><a href="Arbiter.html" title="class in <Unnamed>">Arbiter</a></li>
+<li><a href="Cortana.html" title="class in <Unnamed>">Cortana</a></li>
<li><a href="ErrorHandler.html" title="class in <Unnamed>">ErrorHandler</a></li>
<li><a href="GraphicalUI.html" title="class in <Unnamed>">GraphicalUI</a></li>
<li><a href="Main.html" title="class in <Unnamed>">Main</a></li>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Uses of Class Arbiter (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="../stylesheet.css" title="Style">
<script type="text/javascript" src="../script.js"></script>
</head>
--- /dev/null
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
+<!-- NewPage -->
+<html lang="en">
+<head>
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
+<title>Uses of Class Cortana (Halo 3 Reclaimer)</title>
+<meta name="date" content="2026-11-02">
+<link rel="stylesheet" type="text/css" href="../stylesheet.css" title="Style">
+<script type="text/javascript" src="../script.js"></script>
+</head>
+<body>
+<script type="text/javascript"><!--
+ try {
+ if (location.href.indexOf('is-external=true') == -1) {
+ parent.document.title="Uses of Class Cortana (Halo 3 Reclaimer)";
+ }
+ }
+ catch(err) {
+ }
+//-->
+</script>
+<noscript>
+<div>JavaScript is disabled on your browser.</div>
+</noscript>
+<!-- ========= START OF TOP NAVBAR ======= -->
+<div class="topNav"><a name="navbar.top">
+<!-- -->
+</a>
+<div class="skipNav"><a href="#skip.navbar.top" title="Skip navigation links">Skip navigation links</a></div>
+<a name="navbar.top.firstrow">
+<!-- -->
+</a>
+<ul class="navList" title="Navigation">
+<li><a href="../overview-summary.html">Overview</a></li>
+<li><a href="../package-summary.html">Package</a></li>
+<li><a href="../Cortana.html" title="class in <Unnamed>">Class</a></li>
+<li class="navBarCell1Rev">Use</li>
+<li><a href="../overview-tree.html">Tree</a></li>
+<li><a href="../deprecated-list.html">Deprecated</a></li>
+<li><a href="../index-all.html">Index</a></li>
+<li><a href="../help-doc.html">Help</a></li>
+</ul>
+<div class="aboutLanguage"><b>Halo 3 Reclaimer</b></div>
+</div>
+<div class="subNav">
+<ul class="navList">
+<li>Prev</li>
+<li>Next</li>
+</ul>
+<ul class="navList">
+<li><a href="../index.html?class-use/Cortana.html" target="_top">Frames</a></li>
+<li><a href="Cortana.html" target="_top">No Frames</a></li>
+</ul>
+<ul class="navList" id="allclasses_navbar_top">
+<li><a href="../allclasses-noframe.html">All Classes</a></li>
+</ul>
+<div>
+<script type="text/javascript"><!--
+ allClassesLink = document.getElementById("allclasses_navbar_top");
+ if(window==top) {
+ allClassesLink.style.display = "block";
+ }
+ else {
+ allClassesLink.style.display = "none";
+ }
+ //-->
+</script>
+</div>
+<a name="skip.navbar.top">
+<!-- -->
+</a></div>
+<!-- ========= END OF TOP NAVBAR ========= -->
+<div class="header">
+<h2 title="Uses of Class Cortana" class="title">Uses of Class<br>Cortana</h2>
+</div>
+<div class="classUseContainer">No usage of Cortana</div>
+<!-- ======= START OF BOTTOM NAVBAR ====== -->
+<div class="bottomNav"><a name="navbar.bottom">
+<!-- -->
+</a>
+<div class="skipNav"><a href="#skip.navbar.bottom" title="Skip navigation links">Skip navigation links</a></div>
+<a name="navbar.bottom.firstrow">
+<!-- -->
+</a>
+<ul class="navList" title="Navigation">
+<li><a href="../overview-summary.html">Overview</a></li>
+<li><a href="../package-summary.html">Package</a></li>
+<li><a href="../Cortana.html" title="class in <Unnamed>">Class</a></li>
+<li class="navBarCell1Rev">Use</li>
+<li><a href="../overview-tree.html">Tree</a></li>
+<li><a href="../deprecated-list.html">Deprecated</a></li>
+<li><a href="../index-all.html">Index</a></li>
+<li><a href="../help-doc.html">Help</a></li>
+</ul>
+<div class="aboutLanguage"><i>File extractor from Xbox 360 version of Halo 3</i></div>
+</div>
+<div class="subNav">
+<ul class="navList">
+<li>Prev</li>
+<li>Next</li>
+</ul>
+<ul class="navList">
+<li><a href="../index.html?class-use/Cortana.html" target="_top">Frames</a></li>
+<li><a href="Cortana.html" target="_top">No Frames</a></li>
+</ul>
+<ul class="navList" id="allclasses_navbar_bottom">
+<li><a href="../allclasses-noframe.html">All Classes</a></li>
+</ul>
+<div>
+<script type="text/javascript"><!--
+ allClassesLink = document.getElementById("allclasses_navbar_bottom");
+ if(window==top) {
+ allClassesLink.style.display = "block";
+ }
+ else {
+ allClassesLink.style.display = "none";
+ }
+ //-->
+</script>
+</div>
+<a name="skip.navbar.bottom">
+<!-- -->
+</a></div>
+<!-- ======== END OF BOTTOM NAVBAR ======= -->
+<p class="legalCopy"><small>Copyright 2025. Documentation licensed under GFDL v1.3.</small></p>
+</body>
+</html>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Uses of Class ErrorHandler (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="../stylesheet.css" title="Style">
<script type="text/javascript" src="../script.js"></script>
</head>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Uses of Class GraphicalUI (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="../stylesheet.css" title="Style">
<script type="text/javascript" src="../script.js"></script>
</head>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Uses of Class Main (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="../stylesheet.css" title="Style">
<script type="text/javascript" src="../script.js"></script>
</head>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Uses of Class MapReclaimer (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="../stylesheet.css" title="Style">
<script type="text/javascript" src="../script.js"></script>
</head>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Uses of Class ScreenshotReclaimer (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="../stylesheet.css" title="Style">
<script type="text/javascript" src="../script.js"></script>
</head>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Uses of Class TerminalUI (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="../stylesheet.css" title="Style">
<script type="text/javascript" src="../script.js"></script>
</head>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Constant Field Values (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
<ul class="blockList">
<li class="blockList">
<table class="constantsSummary" border="0" cellpadding="3" cellspacing="0" summary="Constant Field Values table, listing constant fields, and values">
+<caption><span><a href="Arbiter.html" title="class in <Unnamed>">Arbiter</a></span><span class="tabEnd"> </span></caption>
+<tr>
+<th class="colFirst" scope="col">Modifier and Type</th>
+<th scope="col">Constant Field</th>
+<th class="colLast" scope="col">Value</th>
+</tr>
+<tbody>
+<tr class="altColor">
+<td class="colFirst"><a name="Arbiter.MAPS_DIR">
+<!-- -->
+</a><code>public static final java.lang.String</code></td>
+<td><code><a href="Arbiter.html#MAPS_DIR">MAPS_DIR</a></code></td>
+<td class="colLast"><code>"maps-reclaimed"</code></td>
+</tr>
+<tr class="rowColor">
+<td class="colFirst"><a name="Arbiter.SCREENSHOTS_DIR">
+<!-- -->
+</a><code>public static final java.lang.String</code></td>
+<td><code><a href="Arbiter.html#SCREENSHOTS_DIR">SCREENSHOTS_DIR</a></code></td>
+<td class="colLast"><code>"screenshots-reclaimed"</code></td>
+</tr>
+</tbody>
+</table>
+</li>
+<li class="blockList">
+<table class="constantsSummary" border="0" cellpadding="3" cellspacing="0" summary="Constant Field Values table, listing constant fields, and values">
<caption><span><a href="ErrorHandler.html" title="class in <Unnamed>">ErrorHandler</a></span><span class="tabEnd"> </span></caption>
<tr>
<th class="colFirst" scope="col">Modifier and Type</th>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:49 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Deprecated List (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:49 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>API Help (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:49 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Index (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
<!-- -->
</a></div>
<!-- ========= END OF TOP NAVBAR ========= -->
-<div class="contentContainer"><a href="#I:A">A</a> <a href="#I:B">B</a> <a href="#I:C">C</a> <a href="#I:E">E</a> <a href="#I:G">G</a> <a href="#I:M">M</a> <a href="#I:N">N</a> <a href="#I:P">P</a> <a href="#I:S">S</a> <a href="#I:T">T</a> <a href="#I:U">U</a> <a name="I:A">
+<div class="contentContainer"><a href="#I:A">A</a> <a href="#I:B">B</a> <a href="#I:C">C</a> <a href="#I:D">D</a> <a href="#I:E">E</a> <a href="#I:F">F</a> <a href="#I:G">G</a> <a href="#I:M">M</a> <a href="#I:N">N</a> <a href="#I:P">P</a> <a href="#I:S">S</a> <a href="#I:T">T</a> <a href="#I:U">U</a> <a name="I:A">
<!-- -->
</a>
<h2 class="title">A</h2>
<dl>
<dt><span class="memberNameLink"><a href="ErrorHandler.html#CANT_PROCESS">CANT_PROCESS</a></span> - Static variable in class <a href="ErrorHandler.html" title="class in <Unnamed>">ErrorHandler</a></dt>
<dd> </dd>
+<dt><span class="memberNameLink"><a href="Cortana.html#copyHalo3Files-java.io.File-">copyHalo3Files(File)</a></span> - Static method in class <a href="Cortana.html" title="class in <Unnamed>">Cortana</a></dt>
+<dd>
+<div class="block">Copies every Halo 3 file from the stick into a new temporary folder and
+ returns that folder.</div>
+</dd>
+<dt><a href="Cortana.html" title="class in <Unnamed>"><span class="typeNameLink">Cortana</span></a> - Class in <a href="package-summary.html"><Unnamed></a></dt>
+<dd>
+<div class="block">Reader for USB sticks set up by the Xbox 360.</div>
+</dd>
+<dt><span class="memberNameLink"><a href="Cortana.html#Cortana--">Cortana()</a></span> - Constructor for class <a href="Cortana.html" title="class in <Unnamed>">Cortana</a></dt>
+<dd> </dd>
+</dl>
+<a name="I:D">
+<!-- -->
+</a>
+<h2 class="title">D</h2>
+<dl>
+<dt><span class="memberNameLink"><a href="Cortana.html#deleteCopies-java.io.File-">deleteCopies(File)</a></span> - Static method in class <a href="Cortana.html" title="class in <Unnamed>">Cortana</a></dt>
+<dd>
+<div class="block">Deletes the temporary folder made by copyHalo3Files().</div>
+</dd>
</dl>
<a name="I:E">
<!-- -->
<div class="block">TUI only: print error AND QUIT</div>
</dd>
<dt><span class="memberNameLink"><a href="MapReclaimer.html#extractMap-java.io.File-java.io.File-">extractMap(File, File)</a></span> - Method in class <a href="MapReclaimer.html" title="class in <Unnamed>">MapReclaimer</a></dt>
-<dd> </dd>
+<dd>
+<div class="block">Extracts the Forge map from an Xbox 360 file and saves it as
+ <input name>-reclaimed.map in the output directory.</div>
+</dd>
<dt><span class="memberNameLink"><a href="ScreenshotReclaimer.html#extractScreenshot-java.io.File-java.io.File-">extractScreenshot(File, File)</a></span> - Method in class <a href="ScreenshotReclaimer.html" title="class in <Unnamed>">ScreenshotReclaimer</a></dt>
<dd>
<div class="block">Extracts a screenshot from an Xbox 360 file and saves it as a JPEG.</div>
</dd>
</dl>
+<a name="I:F">
+<!-- -->
+</a>
+<h2 class="title">F</h2>
+<dl>
+<dt><span class="memberNameLink"><a href="Cortana.html#findStorage-java.io.File-">findStorage(File)</a></span> - Static method in class <a href="Cortana.html" title="class in <Unnamed>">Cortana</a></dt>
+<dd>
+<div class="block">Returns the Xbox360 folder when the input is a USB stick set up by the
+ console, its Xbox360 folder or one of its Data files.</div>
+</dd>
+</dl>
<a name="I:G">
<!-- -->
</a>
<h2 class="title">G</h2>
<dl>
-<dt><span class="memberNameLink"><a href="Arbiter.html#getFileTypeDescription-java.io.File-">getFileTypeDescription(File)</a></span> - Static method in class <a href="Arbiter.html" title="class in <Unnamed>">Arbiter</a></dt>
-<dd> </dd>
<dt><a href="GraphicalUI.html" title="class in <Unnamed>"><span class="typeNameLink">GraphicalUI</span></a> - Class in <a href="package-summary.html"><Unnamed></a></dt>
<dd>
<div class="block">Graphical User Interface for Halo 3 Reclaimer.</div>
</dd>
<dt><span class="memberNameLink"><a href="MapReclaimer.html#MapReclaimer--">MapReclaimer()</a></span> - Constructor for class <a href="MapReclaimer.html" title="class in <Unnamed>">MapReclaimer</a></dt>
<dd> </dd>
+<dt><span class="memberNameLink"><a href="Arbiter.html#MAPS_DIR">MAPS_DIR</a></span> - Static variable in class <a href="Arbiter.html" title="class in <Unnamed>">Arbiter</a></dt>
+<dd> </dd>
</dl>
<a name="I:N">
<!-- -->
<h2 class="title">P</h2>
<dl>
<dt><span class="memberNameLink"><a href="Arbiter.html#processFile-java.io.File-java.io.File-">processFile(File, File)</a></span> - Static method in class <a href="Arbiter.html" title="class in <Unnamed>">Arbiter</a></dt>
-<dd> </dd>
+<dd>
+<div class="block">Reclaims all screenshots and maps found in a file or folder.</div>
+</dd>
<dt><span class="memberNameLink"><a href="TerminalUI.html#processFile-java.lang.String:A-">processFile(String[])</a></span> - Static method in class <a href="TerminalUI.html" title="class in <Unnamed>">TerminalUI</a></dt>
<dd>
<div class="block">processes file extraction from command line.</div>
</dd>
<dt><span class="memberNameLink"><a href="ScreenshotReclaimer.html#ScreenshotReclaimer--">ScreenshotReclaimer()</a></span> - Constructor for class <a href="ScreenshotReclaimer.html" title="class in <Unnamed>">ScreenshotReclaimer</a></dt>
<dd> </dd>
+<dt><span class="memberNameLink"><a href="Arbiter.html#SCREENSHOTS_DIR">SCREENSHOTS_DIR</a></span> - Static variable in class <a href="Arbiter.html" title="class in <Unnamed>">Arbiter</a></dt>
+<dd> </dd>
<dt><span class="memberNameLink"><a href="TerminalUI.html#showHelp--">showHelp()</a></span> - Static method in class <a href="TerminalUI.html" title="class in <Unnamed>">TerminalUI</a></dt>
<dd> </dd>
<dt><span class="memberNameLink"><a href="TerminalUI.html#showTooManyArguments--">showTooManyArguments()</a></span> - Static method in class <a href="TerminalUI.html" title="class in <Unnamed>">TerminalUI</a></dt>
<dt><span class="memberNameLink"><a href="ErrorHandler.html#UNKNOWN_FILE">UNKNOWN_FILE</a></span> - Static variable in class <a href="ErrorHandler.html" title="class in <Unnamed>">ErrorHandler</a></dt>
<dd> </dd>
</dl>
-<a href="#I:A">A</a> <a href="#I:B">B</a> <a href="#I:C">C</a> <a href="#I:E">E</a> <a href="#I:G">G</a> <a href="#I:M">M</a> <a href="#I:N">N</a> <a href="#I:P">P</a> <a href="#I:S">S</a> <a href="#I:T">T</a> <a href="#I:U">U</a> </div>
+<a href="#I:A">A</a> <a href="#I:B">B</a> <a href="#I:C">C</a> <a href="#I:D">D</a> <a href="#I:E">E</a> <a href="#I:F">F</a> <a href="#I:G">G</a> <a href="#I:M">M</a> <a href="#I:N">N</a> <a href="#I:P">P</a> <a href="#I:S">S</a> <a href="#I:T">T</a> <a href="#I:U">U</a> </div>
<!-- ======= START OF BOTTOM NAVBAR ====== -->
<div class="bottomNav"><a name="navbar.bottom">
<!-- -->
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:49 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Halo 3 Reclaimer</title>
<script type="text/javascript">
tmpTargetPage = "" + window.location.search;
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:49 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Overview (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:49 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Class Hierarchy (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
</li>
</ul>
</li>
+<li type="circle"><a href="Cortana.html" title="class in <Unnamed>"><span class="typeNameLink">Cortana</span></a></li>
<li type="circle"><a href="ErrorHandler.html" title="class in <Unnamed>"><span class="typeNameLink">ErrorHandler</span></a></li>
<li type="circle"><a href="Main.html" title="class in <Unnamed>"><span class="typeNameLink">Main</span></a></li>
<li type="circle"><a href="MapReclaimer.html" title="class in <Unnamed>"><span class="typeNameLink">MapReclaimer</span></a></li>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title><Unnamed> (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
<h2 title="Classes">Classes</h2>
<ul title="Classes">
<li><a href="Arbiter.html" title="class in <Unnamed>" target="classFrame">Arbiter</a></li>
+<li><a href="Cortana.html" title="class in <Unnamed>" target="classFrame">Cortana</a></li>
<li><a href="ErrorHandler.html" title="class in <Unnamed>" target="classFrame">ErrorHandler</a></li>
<li><a href="GraphicalUI.html" title="class in <Unnamed>" target="classFrame">GraphicalUI</a></li>
<li><a href="Main.html" title="class in <Unnamed>" target="classFrame">Main</a></li>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title> (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
</td>
</tr>
<tr class="rowColor">
+<td class="colFirst"><a href="Cortana.html" title="class in <Unnamed>">Cortana</a></td>
+<td class="colLast">
+<div class="block">Reader for USB sticks set up by the Xbox 360.</div>
+</td>
+</tr>
+<tr class="altColor">
<td class="colFirst"><a href="ErrorHandler.html" title="class in <Unnamed>">ErrorHandler</a></td>
<td class="colLast">
<div class="block">Simple error handling class.</div>
</td>
</tr>
-<tr class="altColor">
+<tr class="rowColor">
<td class="colFirst"><a href="GraphicalUI.html" title="class in <Unnamed>">GraphicalUI</a></td>
<td class="colLast">
<div class="block">Graphical User Interface for Halo 3 Reclaimer.</div>
</td>
</tr>
-<tr class="rowColor">
+<tr class="altColor">
<td class="colFirst"><a href="Main.html" title="class in <Unnamed>">Main</a></td>
<td class="colLast">
<div class="block">Main entry point for the program.</div>
</td>
</tr>
-<tr class="altColor">
+<tr class="rowColor">
<td class="colFirst"><a href="MapReclaimer.html" title="class in <Unnamed>">MapReclaimer</a></td>
<td class="colLast">
<div class="block">Core extraction engine for Forge map variants.</div>
</td>
</tr>
-<tr class="rowColor">
+<tr class="altColor">
<td class="colFirst"><a href="ScreenshotReclaimer.html" title="class in <Unnamed>">ScreenshotReclaimer</a></td>
<td class="colLast">
<div class="block">Core extraction engine for screenshots.</div>
</td>
</tr>
-<tr class="altColor">
+<tr class="rowColor">
<td class="colFirst"><a href="TerminalUI.html" title="class in <Unnamed>">TerminalUI</a></td>
<td class="colLast">
<div class="block">Terminal User Interface for Halo 3 Reclaimer.</div>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title> Class Hierarchy (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
</li>
</ul>
</li>
+<li type="circle"><a href="Cortana.html" title="class in <Unnamed>"><span class="typeNameLink">Cortana</span></a></li>
<li type="circle"><a href="ErrorHandler.html" title="class in <Unnamed>"><span class="typeNameLink">ErrorHandler</span></a></li>
<li type="circle"><a href="Main.html" title="class in <Unnamed>"><span class="typeNameLink">Main</span></a></li>
<li type="circle"><a href="MapReclaimer.html" title="class in <Unnamed>"><span class="typeNameLink">MapReclaimer</span></a></li>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Uses of Package (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
<!-- NewPage -->
<html lang="en">
<head>
-<!-- Generated by javadoc (1.8.0_432) on Sat Jul 19 01:19:48 CEST 2025 -->
+<!-- Generated by javadoc (1.8.0_504) on Mon Nov 02 17:45:23 CET 2026 -->
<title>Serialized Form (Halo 3 Reclaimer)</title>
-<meta name="date" content="2025-07-19">
+<meta name="date" content="2026-11-02">
<link rel="stylesheet" type="text/css" href="stylesheet.css" title="Style">
<script type="text/javascript" src="script.js"></script>
</head>
/*
* Halo 3 Reclaimer [Arbiter.java]
-* player data extractor from Xbox 360 version of Halo 3
-* copyright 2025, David Polakovic, licensed under GPLv3
+* player data extractor from Xbox 360 version of Halo 3
+* copyright 2025, 2026, David Polakovic, licensed under GPLv3
* contact: email@dpolakovic.space
*/
+import java.io.DataInputStream;
import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
/**
* Arbiter class that determines file type and routes to appropriate extractor.
-*
-* <p>This class analyzes the input file to determine whether it's a screenshot
-* or map file, then calls the appropriate reclaimer. The Xbox 360 saves file
-* type directly in file name. So it just checks for names...</p>
-*
-* @version 1.0
+*
+* <p>The input is a single file, a whole folder, or a USB stick set up by the
+* Xbox 360. A stick (or its Xbox360 folder, or one of its Data files) goes to
+* Cortana first, which copies the Halo 3 files out of the Xbox file system;
+* other files on the stick are ignored then.</p>
+*
+* <p>Halo 3 keeps every screenshot and every Forge map in its own package,
+* so the Arbiter looks inside each file: a package holding
+* <code>screen.shot</code> is a screenshot, one holding <code>sandbox.map</code>
+* is a map. Screenshots are checked first, then maps. Anything else is skipped.
+* Only when the content says nothing (DDS, TGA, raw textures) the file name
+* decides, like in version 1.0.</p>
+*
+* <p>Results go to two folders in the output directory:
+* <code>screenshots-reclaimed</code> and <code>maps-reclaimed</code>.</p>
+*
+* @version 1.1
*/
public class Arbiter {
-
- public static void processFile(File inputFile, File outputDir) throws Exception {
- String fileName = inputFile.getName().toLowerCase();
-
- if (isScreenshotFile(fileName)) {
- // route to screenshot extractor
- ScreenshotReclaimer screenshotReclaimer = new ScreenshotReclaimer();
- screenshotReclaimer.extractScreenshot(inputFile, outputDir);
- } else if (isMapFile(fileName)) {
- // route to map extractor
- MapReclaimer mapReclaimer = new MapReclaimer();
- mapReclaimer.extractMap(inputFile, outputDir);
- } else {
- // unrecognized file type
- throw ErrorHandler.makeException(ErrorHandler.UNKNOWN_FILE);
- }
- }
-
+
+ // folders created inside the output directory
+ public static final String SCREENSHOTS_DIR = "screenshots-reclaimed";
+ public static final String MAPS_DIR = "maps-reclaimed";
+
+ // the file inside a package that holds a screenshot
+ private static final String SCREENSHOT_FILE = "screen.shot";
+
+ // packages are small, bigger files are not worth reading
+ private static final long MAX_FILE_SIZE = 16L * 1024 * 1024;
+
+ // how deep to look into sub folders
+ private static final int MAX_FOLDER_DEPTH = 8;
+
+ private enum Kind { SCREENSHOT, MAP, OTHER }
+
+ /**
+ * Reclaims all screenshots and maps found in a file or folder.
+ *
+ * @return what was reclaimed, for example "Reclaimed 2 pictures and 1 map"
+ * @throws Exception if nothing could be reclaimed
+ */
+ public static String processFile(File input, File outputDir) throws Exception {
+ // a USB stick set up by the console: Cortana gets the Halo 3 files out of it first
+ File storage = Cortana.findStorage(input);
+ if (storage != null) {
+ File copies = Cortana.copyHalo3Files(storage);
+ try {
+ return reclaim(listFiles(copies), outputDir, true, copies);
+ } finally {
+ Cortana.deleteCopies(copies);
+ }
+ }
+
+ if (input.isDirectory()) {
+ return reclaim(listFiles(input), outputDir, true, input);
+ }
+ List<File> files = new ArrayList<File>();
+ files.add(input);
+ return reclaim(files, outputDir, false, null);
+ }
+
+ /**
+ * Sorts the files out and runs the right reclaimer on each, screenshots first.
+ * folder: there are many files to go through, root: where console paths start.
+ */
+ private static String reclaim(List<File> files, File outputDir, boolean folder, File root) throws Exception {
+ // sort everything out first, screenshots go first
+ List<File> screenshots = new ArrayList<File>();
+ List<File> maps = new ArrayList<File>();
+ String skipReason = null;
+ for (File file : files) {
+ StringBuilder reason = new StringBuilder();
+ StringBuilder detail = new StringBuilder();
+ Kind kind = detect(file, reason, detail);
+ if (kind == Kind.SCREENSHOT) {
+ screenshots.add(file);
+ } else if (kind == Kind.MAP) {
+ maps.add(file);
+ } else {
+ // short reason for the status bar, the detail only goes to the console
+ skipReason = reason.toString();
+ if (folder) {
+ System.out.println("Skipping " + shown(root, file) + " ("
+ + (detail.length() > 0 ? detail : skipReason) + ")");
+ } else if (detail.length() > 0) {
+ System.out.println("Not reclaimed: " + detail);
+ }
+ }
+ }
+
+ if (screenshots.isEmpty() && maps.isEmpty()) {
+ if (folder) {
+ throw ErrorHandler.makeException(ErrorHandler.UNKNOWN_FILE + ": no Halo 3 files found");
+ }
+ throw ErrorHandler.makeException(skipReason);
+ }
+ if (folder) {
+ System.out.println("Found " + count(screenshots.size(), "screenshot")
+ + " and " + count(maps.size(), "map"));
+ }
+
+ int failed = 0;
+ Exception lastError = null;
+
+ int pictures = 0;
+ if (!screenshots.isEmpty()) {
+ File screenshotsDir = outputFolder(outputDir, SCREENSHOTS_DIR);
+ for (File file : screenshots) {
+ System.out.println("Detected as screenshot: " + shown(root, file));
+ try {
+ new ScreenshotReclaimer().extractScreenshot(file, screenshotsDir);
+ pictures++;
+ } catch (Exception e) {
+ failed++;
+ lastError = e;
+ System.out.println("Could not reclaim " + shown(root, file) + ": " + e.getMessage());
+ }
+ }
+ }
+
+ int reclaimedMaps = 0;
+ if (!maps.isEmpty()) {
+ File mapsDir = outputFolder(outputDir, MAPS_DIR);
+ for (File file : maps) {
+ System.out.println("Detected as Forge map: " + shown(root, file));
+ try {
+ new MapReclaimer().extractMap(file, mapsDir);
+ reclaimedMaps++;
+ } catch (Exception e) {
+ failed++;
+ lastError = e;
+ System.out.println("Could not reclaim " + shown(root, file) + ": " + e.getMessage());
+ }
+ }
+ }
+
+ if (pictures + reclaimedMaps == 0) {
+ if (failed == 1) {
+ throw lastError;
+ }
+ throw ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": all " + failed + " files failed");
+ }
+
+ List<String> reclaimed = new ArrayList<String>();
+ if (pictures > 0) reclaimed.add(count(pictures, "picture"));
+ if (reclaimedMaps > 0) reclaimed.add(count(reclaimedMaps, "map"));
+ String result = "Reclaimed " + String.join(" and ", reclaimed);
+ if (failed > 0) {
+ result += " (" + failed + " failed)";
+ }
+ return result;
+ }
+
+ /**
+ * Tells what a file is by looking inside it. Screenshots are checked first.
+ * When the file is neither, a short error goes into reason and, when
+ * there is more to say, an explanation for the console into detail.
+ */
+ private static Kind detect(File file, StringBuilder reason, StringBuilder detail) {
+ if (file.length() > MAX_FILE_SIZE) {
+ reason.append(ErrorHandler.CANT_PROCESS + ": file too big");
+ detail.append(file.length() + " bytes, too big for a screenshot or map");
+ return Kind.OTHER;
+ }
+
+ byte[] data;
+ try {
+ data = readFile(file);
+ } catch (IOException e) {
+ reason.append(ErrorHandler.BAD_INPUT + ": " + e.getMessage());
+ return Kind.OTHER;
+ }
+
+ if (MapReclaimer.isSTFSFile(data)) {
+ try {
+ List<String> names = MapReclaimer.listPackageFiles(data);
+ if (containsName(names, SCREENSHOT_FILE)) return Kind.SCREENSHOT;
+ if (containsName(names, MapReclaimer.MAP_VARIANT_FILE)) return Kind.MAP;
+ // game types, films...
+ reason.append(ErrorHandler.CANT_PROCESS + ": not a screenshot or map");
+ detail.append(names.isEmpty() ? "the package is empty"
+ : "the package holds " + String.join(", ", names));
+ return Kind.OTHER;
+ } catch (Exception e) {
+ // another game or a damaged package, the name may still tell
+ reason.append(e.getMessage() != null ? e.getMessage() : ErrorHandler.CANT_PROCESS);
+ }
+ } else if (MapReclaimer.isBLFFile(data)) {
+ // screen.shot or sandbox.map already taken out of its package
+ int fileType = MapReclaimer.readBlfFileType(data);
+ if (fileType == MapReclaimer.FILE_TYPE_SCREENSHOT) return Kind.SCREENSHOT;
+ if (fileType == MapReclaimer.FILE_TYPE_MAP_VARIANT) return Kind.MAP;
+ if (fileType >= 0) {
+ reason.append(ErrorHandler.CANT_PROCESS + ": not a screenshot or map");
+ detail.append("Halo file of type " + fileType + " (game type, film...)");
+ return Kind.OTHER;
+ }
+ }
+
+ // the content says nothing (DDS, TGA, raw textures), let the name decide
+ String fileName = file.getName().toLowerCase();
+ if (isScreenshotFile(fileName)) return Kind.SCREENSHOT;
+ if (isMapFile(fileName)) return Kind.MAP;
+
+ if (reason.length() == 0) {
+ reason.append(ErrorHandler.UNKNOWN_FILE);
+ }
+ return Kind.OTHER;
+ }
private static boolean isScreenshotFile(String fileName) {
return fileName.contains("screenshot") || fileName.contains("shot");
}
-
private static boolean isMapFile(String fileName) {
return fileName.contains("map") || fileName.contains("variant") || fileName.contains("forge");
}
-
- public static String getFileTypeDescription(File inputFile) {
- String fileName = inputFile.getName().toLowerCase();
-
- if (isScreenshotFile(fileName)) {
- return "Screenshot file";
- } else if (isMapFile(fileName)) {
- return "Map file";
- } else {
- return "Unrecognized file";
+
+ private static boolean containsName(List<String> names, String wanted) {
+ for (String name : names) {
+ if (name.equalsIgnoreCase(wanted)) return true;
+ }
+ return false;
+ }
+
+ private static List<File> listFiles(File folder) {
+ List<File> files = new ArrayList<File>();
+ collectFiles(folder, files, 0);
+ return files;
+ }
+
+ // path for the console, starting at the picked folder
+ private static String shown(File root, File file) {
+ if (root == null) return file.getPath();
+ try {
+ return root.toPath().relativize(file.toPath()).toString();
+ } catch (IllegalArgumentException e) {
+ return file.getPath();
}
}
-}
\ No newline at end of file
+
+ /**
+ * Adds all files of a folder and its sub folders, skipping our own output folders.
+ */
+ private static void collectFiles(File folder, List<File> files, int depth) {
+ if (depth > MAX_FOLDER_DEPTH) return;
+ File[] children = folder.listFiles();
+ if (children == null) return;
+ Arrays.sort(children);
+ for (File child : children) {
+ if (child.isDirectory()) {
+ String name = child.getName();
+ if (!name.equals(SCREENSHOTS_DIR) && !name.equals(MAPS_DIR)) {
+ collectFiles(child, files, depth + 1);
+ }
+ } else if (child.isFile()) {
+ files.add(child);
+ }
+ }
+ }
+
+ /**
+ * Returns the output sub folder, creates it when needed.
+ */
+ private static File outputFolder(File outputDir, String name) throws Exception {
+ File folder = new File(outputDir, name);
+ if (!folder.isDirectory() && !folder.mkdirs()) {
+ throw ErrorHandler.makeException(ErrorHandler.BAD_OUTPUT + ": " + folder.getPath());
+ }
+ return folder;
+ }
+
+ private static byte[] readFile(File file) throws IOException {
+ byte[] data = new byte[(int) file.length()];
+ try (DataInputStream in = new DataInputStream(new FileInputStream(file))) {
+ in.readFully(data);
+ }
+ return data;
+ }
+
+ // "1 map", "2 maps"
+ private static String count(int number, String word) {
+ return number + " " + word + (number == 1 ? "" : "s");
+ }
+}
--- /dev/null
+/*
+* Halo 3 Reclaimer [Cortana.java]
+* player data extractor from Xbox 360 version of Halo 3
+* copyright 2026, David Polakovic, licensed under GPLv3
+* contact: email@dpolakovic.space
+*/
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+* Reader for USB sticks set up by the Xbox 360.
+*
+* <p>The console doesn't store saves on the stick as normal files. It creates
+* a hidden folder <code>Xbox360</code> with big files <code>Data0000</code>,
+* <code>Data0001</code>, <code>Data0002</code>... Joined together they are one
+* virtual drive in the Xbox's own FATX file system. Cortana opens that drive,
+* walks to the Halo 3 folders (<code>Content\<profile>\4D5307E6\...</code>)
+* and copies their files into a temporary folder, where the Arbiter sorts them
+* out like any other files. Nothing on the stick is ever changed.</p>
+*
+* <p>Only Cortana can understand these needlesly complicated Michaelsoft file
+* systems.</p>
+*
+* <p>Partition offsets and the FATX layout follow Free60 and Velocity
+* (hetelek/Velocity, GPLv3).</p>
+*
+* @version 1.0
+*/
+
+public class Cortana {
+
+ // the hidden folder the console creates on the stick
+ private static final String STORAGE_FOLDER = "Xbox360";
+
+ // Halo 3 keeps its files in folders named after its title ID
+ private static final String HALO3_FOLDER = "4D5307E6";
+
+ // user content starts 512 MB into the joined Data files
+ private static final long CONTENT_PARTITION = 0x20000000L;
+
+ // FATX format constants
+ private static final int FATX_MAGIC = 0x58544146; // "XTAF"
+ private static final int FATX_HEADER_SIZE = 0x1000; // the allocation table follows it
+ private static final int ENTRY_SIZE = 0x40;
+ private static final int ENTRY_NAME_LENGTH = 0x2A;
+ private static final int ENTRY_DELETED = 0xE5;
+ private static final int ATTRIBUTE_DIRECTORY = 0x10;
+
+ // screenshots and maps are small, add-ons and updates stay on the stick
+ private static final long MAX_FILE_SIZE = 16L * 1024 * 1024;
+
+ // how deep to look for the Halo 3 folders, and how long a folder may get
+ private static final int MAX_DEPTH = 6;
+ private static final int MAX_FOLDER_CLUSTERS = 1024;
+
+ /**
+ * Returns the Xbox360 folder when the input is a USB stick set up by the
+ * console, its Xbox360 folder or one of its Data files. Otherwise null.
+ */
+ public static File findStorage(File input) {
+ if (input == null) return null;
+
+ if (input.isFile()) {
+ // one of the Data files was picked
+ File parent = input.getParentFile();
+ if (input.getName().matches("(?i)data\\d{4}") && parent != null && hasDataFiles(parent)) {
+ return parent;
+ }
+ return null;
+ }
+
+ if (input.isDirectory()) {
+ if (hasDataFiles(input)) return input;
+ File storage = findChild(input, STORAGE_FOLDER);
+ if (storage != null && storage.isDirectory() && hasDataFiles(storage)) {
+ return storage;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Copies every Halo 3 file from the stick into a new temporary folder and
+ * returns that folder. Remove it with deleteCopies() when done.
+ *
+ * @throws Exception if the stick can't be read
+ */
+ public static File copyHalo3Files(File storage) throws Exception {
+ List<File> dataFiles = listDataFiles(storage);
+ System.out.println("Reading Xbox 360 USB storage: " + storage.getPath()
+ + " (" + dataFiles.size() + " Data files)");
+
+ File copies;
+ try {
+ copies = Files.createTempDirectory("halo3-reclaimer-").toFile();
+ } catch (IOException e) {
+ throw ErrorHandler.makeException(ErrorHandler.BAD_OUTPUT + ": no temporary folder");
+ }
+
+ try (Drive drive = new Drive(dataFiles)) {
+ if (drive.length <= CONTENT_PARTITION + FATX_HEADER_SIZE) {
+ throw unreadable("the Data files hold only " + drive.length + " bytes");
+ }
+ Volume content = new Volume(drive, CONTENT_PARTITION, drive.length - CONTENT_PARTITION, true);
+ int copied = copyFolder(content, content.root(), copies, "", false, 0);
+ System.out.println("Copied " + copied + " Halo 3 file(s) off the stick");
+ } catch (RuntimeException e) {
+ // a bug or a layout nobody has seen yet
+ deleteCopies(copies);
+ e.printStackTrace();
+ throw ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": USB stick not readable");
+ } catch (IOException e) {
+ deleteCopies(copies);
+ throw ErrorHandler.makeException(ErrorHandler.BAD_INPUT + ": " + e.getMessage());
+ } catch (Exception e) {
+ deleteCopies(copies);
+ throw e;
+ }
+ return copies;
+ }
+
+ /**
+ * Deletes the temporary folder made by copyHalo3Files().
+ */
+ public static void deleteCopies(File folder) {
+ if (folder == null) return;
+ File[] children = folder.listFiles();
+ if (children != null) {
+ for (File child : children) {
+ if (child.isDirectory()) {
+ deleteCopies(child);
+ } else {
+ child.delete();
+ }
+ }
+ }
+ folder.delete();
+ }
+
+ /**
+ * Walks the folders. Inside a 4D5307E6 folder every file is copied out.
+ */
+ private static int copyFolder(Volume volume, Entry folder, File target, String path,
+ boolean inHalo3, int depth) throws Exception {
+ if (depth > MAX_DEPTH) return 0;
+
+ int copied = 0;
+ for (Entry entry : volume.list(folder)) {
+ String entryPath = path.isEmpty() ? entry.name : path + "\\" + entry.name;
+ File entryTarget = new File(target, safeName(entry.name));
+
+ if (entry.isDirectory()) {
+ boolean halo3 = inHalo3 || entry.name.equalsIgnoreCase(HALO3_FOLDER);
+ if (halo3 && !inHalo3) {
+ System.out.println("Found Halo 3 folder: " + entryPath);
+ }
+ copied += copyFolder(volume, entry, entryTarget, entryPath, halo3, depth + 1);
+ } else if (inHalo3) {
+ if (entry.size > MAX_FILE_SIZE) {
+ System.out.println("Leaving " + entryPath + " on the stick (" + entry.size
+ + " bytes, too big for a screenshot or map)");
+ continue;
+ }
+ writeFile(entryTarget, volume.read(entry));
+ copied++;
+ }
+ }
+ return copied;
+ }
+
+ /**
+ * Data0000, Data0001... in order, until one is missing.
+ */
+ private static List<File> listDataFiles(File storage) {
+ List<File> files = new ArrayList<File>();
+ for (int i = 0; i < 10000; i++) {
+ File data = findChild(storage, String.format("Data%04d", i));
+ if (data == null || !data.isFile()) break;
+ files.add(data);
+ }
+ return files;
+ }
+
+ private static boolean hasDataFiles(File folder) {
+ File first = findChild(folder, "Data0000");
+ return first != null && first.isFile();
+ }
+
+ /**
+ * Finds a file or folder by name, ignoring upper and lower case.
+ */
+ private static File findChild(File folder, String name) {
+ File exact = new File(folder, name);
+ if (exact.exists()) return exact;
+ String[] names = folder.list();
+ if (names == null) return null;
+ for (String candidate : names) {
+ if (candidate.equalsIgnoreCase(name)) return new File(folder, candidate);
+ }
+ return null;
+ }
+
+ private static void writeFile(File file, byte[] data) throws Exception {
+ File parent = file.getParentFile();
+ if (parent != null && !parent.isDirectory() && !parent.mkdirs()) {
+ throw ErrorHandler.makeException(ErrorHandler.BAD_OUTPUT + ": no temporary folder");
+ }
+ try (FileOutputStream out = new FileOutputStream(file)) {
+ out.write(data);
+ } catch (IOException e) {
+ throw ErrorHandler.makeException(ErrorHandler.BAD_OUTPUT + ": " + e.getMessage());
+ }
+ }
+
+ // Xbox names are plain, but never trust them with the PC's file system
+ private static String safeName(String name) {
+ String safe = name.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_").trim();
+ return (safe.isEmpty() || safe.equals(".") || safe.equals("..")) ? "_" : safe;
+ }
+
+ // short message for the status bar, the reason goes to the console
+ private static Exception damaged(String reason) {
+ System.out.println("Damaged USB stick: " + reason);
+ return ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": damaged USB stick");
+ }
+
+ private static Exception unreadable(String reason) {
+ System.out.println("USB stick not readable: " + reason);
+ return ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": USB stick not readable");
+ }
+
+ private static int readInt32(byte[] data, int offset) {
+ return ((data[offset] & 0xFF) << 24) | ((data[offset + 1] & 0xFF) << 16)
+ | ((data[offset + 2] & 0xFF) << 8) | (data[offset + 3] & 0xFF);
+ }
+
+ /**
+ * One file or folder on the Xbox drive.
+ */
+ private static final class Entry {
+ String name;
+ int attributes;
+ long firstCluster;
+ long size;
+
+ boolean isDirectory() {
+ return (attributes & ATTRIBUTE_DIRECTORY) != 0;
+ }
+ }
+
+ /**
+ * The Data files joined into one long drive. Only the needed parts are read,
+ * the files can be gigabytes.
+ */
+ private static final class Drive implements Closeable {
+ private final RandomAccessFile[] files;
+ private final long[] starts;
+ private final long[] lengths;
+ final long length;
+
+ Drive(List<File> dataFiles) throws IOException {
+ files = new RandomAccessFile[dataFiles.size()];
+ starts = new long[files.length];
+ lengths = new long[files.length];
+ long total = 0;
+ try {
+ for (int i = 0; i < files.length; i++) {
+ files[i] = new RandomAccessFile(dataFiles.get(i), "r");
+ starts[i] = total;
+ lengths[i] = files[i].length();
+ total += lengths[i];
+ }
+ } catch (IOException e) {
+ close();
+ throw e;
+ }
+ length = total;
+ }
+
+ /**
+ * Reads count bytes at a position of the joined drive.
+ */
+ byte[] read(long position, int count) throws Exception {
+ if (position < 0 || count < 0 || position + count > length) {
+ throw damaged("something points past the end of the Data files");
+ }
+ byte[] out = new byte[count];
+ int done = 0;
+ int index = 0;
+ while (done < count) {
+ long pos = position + done;
+ while (pos >= starts[index] + lengths[index]) {
+ index++;
+ }
+ long inFile = pos - starts[index];
+ int chunk = (int) Math.min(count - done, lengths[index] - inFile);
+ files[index].seek(inFile);
+ files[index].readFully(out, done, chunk);
+ done += chunk;
+ }
+ return out;
+ }
+
+ @Override
+ public void close() {
+ for (RandomAccessFile file : files) {
+ if (file != null) {
+ try {
+ file.close();
+ } catch (IOException e) {
+ // nothing left to do
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * One FATX partition: a 4 KB header, the allocation table, then the clusters.
+ */
+ private static final class Volume {
+ private final Drive drive;
+ private final long rootCluster;
+ private final int clusterSize;
+ private final int fatEntrySize; // 2 or 4 bytes
+ private final long fatStart;
+ private final long clusterStart;
+ private final long clusterCount;
+
+ Volume(Drive drive, long start, long size, boolean usbContent) throws Exception {
+ this.drive = drive;
+
+ // header: magic, partition ID, sectors per cluster, root folder cluster
+ byte[] header = drive.read(start, 16);
+ if (readInt32(header, 0) != FATX_MAGIC) {
+ throw unreadable("no Xbox file system where the content should start");
+ }
+ int sectorsPerCluster = readInt32(header, 8);
+ rootCluster = readInt32(header, 12) & 0xFFFFFFFFL;
+ switch (sectorsPerCluster) {
+ case 0x2: case 0x4: case 0x8: case 0x10: case 0x20: case 0x40: case 0x80:
+ break;
+ default:
+ throw damaged("unknown cluster size");
+ }
+ clusterSize = sectorsPerCluster << 9;
+ int shift = Integer.numberOfTrailingZeros(clusterSize);
+
+ // size of the allocation table, worked out the same way as the console does;
+ // the content partition of a USB stick always uses 4 byte entries
+ long clusters = (size >> shift) + 1;
+ int entryShift = (usbContent || clusters >= 0xFFF0) ? 2 : 1;
+ long fatSize = ((clusters << entryShift) + 0xFFF) & ~0xFFFL & 0xFFFFFFFFL;
+ long rest = size - FATX_HEADER_SIZE;
+ if (rest < fatSize) {
+ throw damaged("partition too small for its allocation table");
+ }
+
+ fatEntrySize = 1 << entryShift;
+ fatStart = start + FATX_HEADER_SIZE;
+ clusterStart = fatStart + fatSize;
+ clusterCount = (rest - fatSize) >> shift;
+ }
+
+ Entry root() {
+ Entry root = new Entry();
+ root.name = "";
+ root.attributes = ATTRIBUTE_DIRECTORY;
+ root.firstCluster = rootCluster;
+ return root;
+ }
+
+ /**
+ * Files and folders inside a folder, deleted ones left out.
+ */
+ List<Entry> list(Entry folder) throws Exception {
+ List<Entry> entries = new ArrayList<Entry>();
+ long cluster = folder.firstCluster;
+ int visited = 0;
+ while (isCluster(cluster)) {
+ if (++visited > MAX_FOLDER_CLUSTERS) {
+ throw damaged("a folder never ends");
+ }
+ byte[] data = drive.read(clusterOffset(cluster), clusterSize);
+ for (int pos = 0; pos + ENTRY_SIZE <= data.length; pos += ENTRY_SIZE) {
+ // entry: name length, attributes, 42 byte name, first cluster, size, 3 dates
+ int nameLength = data[pos] & 0xFF;
+ if (nameLength == 0x00 || nameLength == 0xFF) {
+ return entries; // end of the folder
+ }
+ if (nameLength == ENTRY_DELETED || nameLength > ENTRY_NAME_LENGTH) {
+ continue; // deleted
+ }
+ Entry entry = new Entry();
+ entry.attributes = data[pos + 1] & 0xFF;
+ entry.name = new String(data, pos + 2, nameLength, StandardCharsets.ISO_8859_1);
+ entry.firstCluster = readInt32(data, pos + 0x2C) & 0xFFFFFFFFL;
+ entry.size = readInt32(data, pos + 0x30) & 0xFFFFFFFFL;
+ entries.add(entry);
+ }
+ cluster = nextCluster(cluster);
+ }
+ return entries;
+ }
+
+ /**
+ * The contents of a file, following its cluster chain.
+ */
+ byte[] read(Entry file) throws Exception {
+ byte[] out = new byte[(int) file.size];
+ long cluster = file.firstCluster;
+ int done = 0;
+ while (done < out.length) {
+ if (!isCluster(cluster)) {
+ throw damaged("a file is cut off");
+ }
+ int chunk = Math.min(clusterSize, out.length - done);
+ byte[] part = drive.read(clusterOffset(cluster), chunk);
+ System.arraycopy(part, 0, out, done, chunk);
+ done += chunk;
+ if (done < out.length) {
+ cluster = nextCluster(cluster);
+ }
+ }
+ return out;
+ }
+
+ // clusters count from 1; free (0) and end markers fall outside this range
+ private boolean isCluster(long cluster) {
+ return cluster >= 1 && cluster <= clusterCount;
+ }
+
+ private long clusterOffset(long cluster) {
+ return clusterStart + (cluster - 1) * clusterSize;
+ }
+
+ // the allocation table holds the next cluster of every cluster
+ private long nextCluster(long cluster) throws Exception {
+ byte[] entry = drive.read(fatStart + cluster * fatEntrySize, fatEntrySize);
+ if (fatEntrySize == 4) {
+ return readInt32(entry, 0) & 0xFFFFFFFFL;
+ }
+ return ((entry[0] & 0xFF) << 8) | (entry[1] & 0xFF);
+ }
+ }
+}
/*
* Halo 3 Reclaimer [GraphicalUI.java]
* player data extractor from Xbox 360 version of Halo 3
-* copyright 2025, David Polakovic, licensed under GPLv3
+* copyright 2025, 2026, David Polakovic, licensed under GPLv3
* contact: email@dpolakovic.space
*/
* <p>This class contains all the GUI components and
* logic for the application's windowed interface</p>
*
-* @version 1.0
+* @version 1.1
*/
public class GraphicalUI extends JFrame {
// the action button
JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.CENTER));
buttonPanel.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0));
- reclaimButton = new JButton("PELICAN!");
+ reclaimButton = new JButton("reclaim");
reclaimButton.setPreferredSize(new Dimension(120, 40));
reclaimButton.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 14));
buttonPanel.add(reclaimButton);
*/
private void showVersionInfo() {
String versionText = "Halo 3 Reclaimer\n\n" +
- "Version: 1.0\n\n" +
+ "Version: 0.2\n\n" +
"player data extractor from Xbox 360 version of Halo 3\n" +
- "copyright 2025, David Polakovic, licensed under GPLv3\n";
+ "copyright 2025, 2026, David Polakovic, licensed under GPLv3\n";
JOptionPane.showMessageDialog(this, versionText, "Version Information",
JOptionPane.INFORMATION_MESSAGE);
" to select your Xbox 360 Halo 3 file\n\n" +
"2. Click 'Browse' next to 'Save destination'\n" +
" to choose where to save the converted image\n\n" +
- "3. Click 'PELICAN!' to start the extraction process\n\n" +
- "The tool will convert your Xbox 360 file\n" +
- "into a standard JPEG image or map file*\n" +
- "that you share on any device.\n\n" +
- "* map files are not implemented yet";
+ "3. Click 'reclaim' to start the extraction process\n\n" +
+ "The tool will extract your user data from the\n" +
+ "Xbox 360 save. Currently supports:\n" +
+ "-screenshots\n" +
+ "-Forge maps\n\n";
JOptionPane.showMessageDialog(this, helpText, "Help",
JOptionPane.INFORMATION_MESSAGE);
@Override
public void actionPerformed(ActionEvent e) {
JFileChooser fileChooser = new JFileChooser();
- fileChooser.setFileSelectionMode(JFileChooser.FILES_ONLY);
- fileChooser.setDialogTitle("Select Halo 3 raw file");
+ fileChooser.setFileSelectionMode(JFileChooser.FILES_AND_DIRECTORIES);
+ fileChooser.setFileHidingEnabled(false); // the Xbox360 folder on a stick is hidden
+ fileChooser.setDialogTitle("Select Halo 3 file or folder");
// set file filter for Xbox file formats
fileChooser.addChoosableFileFilter(new javax.swing.filechooser.FileFilter() {
resetStatusColor();
setStatus("Processing file...");
- SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
+ SwingWorker<String, Void> worker = new SwingWorker<String, Void>() {
@Override
- protected Void doInBackground() throws Exception {
+ protected String doInBackground() throws Exception {
try {
- Arbiter.processFile(rawFile, saveDir);
+ return Arbiter.processFile(rawFile, saveDir);
} catch (Exception ex) {
SwingUtilities.invokeLater(() -> {
setErrorStatus(ex.getMessage());
});
throw ex;
}
- return null;
}
@Override
protected void done() {
reclaimButton.setEnabled(true);
- reclaimButton.setText("PELICAN!");
+ reclaimButton.setText("reclaim");
try {
- get(); // Check if any exception occurred
-
- String baseName = rawFile.getName();
- int dotIndex = baseName.lastIndexOf('.');
- if (dotIndex > 0) {
- baseName = baseName.substring(0, dotIndex);
- }
- String outputFileName = baseName + "-reclaimed.jpg";
-
- setSuccessStatus("Success! Saved as: " + outputFileName);
+ // e.g. "Reclaimed 2 pictures and 1 map", throws if any exception occurred
+ String result = get();
+ setSuccessStatus("Success! " + result);
} catch (Exception ex) { }
}
};
worker.execute();
}
}
-}
\ No newline at end of file
+}
/*
* Halo 3 Reclaimer [MapReclaimer.java]
-* player data extractor from Xbox 360 version of Halo 3
-* copyright 2025, David Polakovic, licensed under GPLv3
+* player data extractor from Xbox 360 version of Halo 3
+* copyright 2025, 2026, David Polakovic, licensed under GPLv3
* contact: email@dpolakovic.space
*/
+import java.io.DataInputStream;
import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
/**
* Core extraction engine for Forge map variants.
-*
-* <p>This is harder than it looks. WIP.</p>
-*
-* @version "1.0"
+*
+* <p>Halo 3 saves every Forge map as its own Xbox 360 STFS package
+* (a CON file with a long hex name). The package holds one file called
+* <code>sandbox.map</code>. That file is the Forge map variant itself:
+* the object placements on top of one of the original maps, stored in
+* Bungie's BLF format (chunks <code>_blf</code>, <code>chdr</code>,
+* <code>mvar</code> and <code>_eof</code>).</p>
+*
+* <p>Unlike screenshots, the map has no magic bytes worth scanning for,
+* so this class reads the STFS file system properly: it walks the file
+* table, follows the block chain of <code>sandbox.map</code> and checks
+* each block against the package hash tables.</p>
+*
+* <p>Supported input formats:</p>
+* <ul>
+* <li>STFS container files (CON, LIVE, PIRS) holding a Halo 3 Forge map</li>
+* <li>already extracted <code>sandbox.map</code> files (BLF)</li>
+* </ul>
+*
+* <p>The saved file is not converted in any way. It is byte for byte the
+* <code>sandbox.map</code> the console keeps inside the package.</p>
+*
+* @version 1.0
*/
public class MapReclaimer {
-
+ // every Halo 3 package carries this title ID in its header
+ private static final int HALO3_TITLE_ID = 0x4D5307E6;
+
+ // a Forge map package is around 100 KB, anything this big is something else
+ private static final long MAX_INPUT_SIZE = 16L * 1024 * 1024;
+
+ // STFS format constants
+ private static final int STFS_MAGIC_CON = 0x434F4E20; // "CON "
+ private static final int STFS_MAGIC_LIVE = 0x4C495645; // "LIVE"
+ private static final int STFS_MAGIC_PIRS = 0x50495253; // "PIRS"
+ private static final int STFS_BLOCK_SIZE = 0x1000;
+ private static final int STFS_BLOCKS_PER_TABLE = 0xAA; // data blocks covered by one hash table
+ private static final int STFS_BLOCKS_PER_LEVEL1 = 0x70E4; // data blocks covered by one level 1 table
+ private static final int STFS_HASH_ENTRY_SIZE = 0x18;
+ private static final int STFS_FILE_ENTRY_SIZE = 0x40;
+
+ // the file inside the package that holds the Forge map
+ static final String MAP_VARIANT_FILE = "sandbox.map";
+
+ // BLF format constants (the format of sandbox.map)
+ private static final int BLF_START = 0x5F626C66; // "_blf"
+ private static final int BLF_END = 0x5F656F66; // "_eof"
+ private static final int BLF_CONTENT_HEADER = 0x63686472; // "chdr"
+ private static final int BLF_MAP_VARIANT = 0x6D766172; // "mvar", Xbox 360
+ private static final int BLF_MAP_VARIANT_PC = 0x6D617076; // "mapv", PC builds of the engine
+ private static final int BLF_CHUNK_HEADER_SIZE = 12;
+
+ // item metadata (name, author...) sits 0x10 bytes into both chdr and mvar,
+ // these offsets are relative to its start
+ private static final int METADATA_OFFSET = 0x10;
+ private static final int METADATA_NAME = 0x08; // UTF-16, 16 characters
+ private static final int METADATA_DESCRIPTION = 0x28; // UTF-8, 128 bytes
+ private static final int METADATA_AUTHOR = 0xA8; // 16 bytes
+ private static final int METADATA_FILE_TYPE = 0xB8;
+ private static final int METADATA_MAP_ID = 0xE0;
+
+ // saved game file types as stored in the metadata
+ static final int FILE_TYPE_MAP_VARIANT = 10;
+ static final int FILE_TYPE_SCREENSHOT = 13;
+ private static final String[] FILE_TYPE_NAMES = {
+ "campaign save", "Capture the Flag game type", "Slayer game type",
+ "Oddball game type", "King of the Hill game type", "Juggernaut game type",
+ "Territories game type", "Assault game type", "Infection game type",
+ "VIP game type", "Forge map", "saved film", "film clip", "screenshot"
+ };
+
+ /**
+ * Extracts the Forge map from an Xbox 360 file and saves it as
+ * <input name>-reclaimed.map in the output directory.
+ *
+ * @throws Exception if the file cannot be read, is not a Halo 3 Forge map,
+ * or is damaged
+ */
public void extractMap(File inputFile, File outputDir) throws Exception {
- throw ErrorHandler.makeException(ErrorHandler.NOT_READY);
+ byte[] fileData = readFile(inputFile);
+
+ System.out.println("File size: " + fileData.length + " bytes");
+
+ byte[] mapData;
+ MapInfo info;
+ try {
+ if (isSTFSFile(fileData)) {
+ System.out.println("Detected STFS file format");
+ mapData = extractFromSTFS(fileData);
+ } else if (isBLFFile(fileData)) {
+ System.out.println("Detected extracted map variant (BLF) file");
+ mapData = fileData;
+ } else {
+ System.out.println("Not an Xbox 360 package or map variant");
+ throw ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": not a Forge map");
+ }
+ info = readMapInfo(mapData);
+ } catch (RuntimeException e) {
+ // a bug or a file layout nobody has seen yet, keep the GUI informed
+ e.printStackTrace();
+ throw ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": damaged or unsupported file");
+ }
+
+ System.out.println("Forge map: \"" + info.name + "\"");
+ if (!info.description.isEmpty()) {
+ System.out.println("Description: " + info.description);
+ }
+ if (!info.author.isEmpty()) {
+ System.out.println("Author: " + info.author);
+ }
+ if (info.mapId >= 0) {
+ System.out.println("Base map ID: " + info.mapId);
+ }
+ System.out.println("Map variant size: " + mapData.length + " bytes");
+
+ // generate output filename, same scheme as the screenshots
+ String baseName = inputFile.getName();
+ int dotIndex = baseName.lastIndexOf('.');
+ if (dotIndex > 0) {
+ baseName = baseName.substring(0, dotIndex);
+ }
+ String outputFileName = baseName + "-reclaimed.map";
+ File outputFile = new File(outputDir, outputFileName);
+
+ writeFile(outputFile, mapData);
+ System.out.println("Saved as: " + outputFile.getAbsolutePath());
+ }
+
+ /**
+ * Reads the entire contents of a file into a byte array.
+ */
+ private byte[] readFile(File file) throws Exception {
+ long length = file.length();
+ if (length > MAX_INPUT_SIZE) {
+ throw ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": file too big for a Forge map");
+ }
+
+ byte[] data = new byte[(int) length];
+ try (DataInputStream in = new DataInputStream(new FileInputStream(file))) {
+ in.readFully(data);
+ } catch (IOException e) {
+ throw ErrorHandler.makeException(ErrorHandler.BAD_INPUT + ": " + e.getMessage());
+ }
+ return data;
+ }
+
+ private void writeFile(File file, byte[] data) throws Exception {
+ try (FileOutputStream out = new FileOutputStream(file)) {
+ out.write(data);
+ } catch (IOException e) {
+ throw ErrorHandler.makeException(ErrorHandler.BAD_OUTPUT + ": " + e.getMessage());
+ }
+ }
+
+ static boolean isSTFSFile(byte[] data) {
+ if (data.length < 4) return false;
+ int magic = readInt32(data, 0);
+ return magic == STFS_MAGIC_CON || magic == STFS_MAGIC_LIVE || magic == STFS_MAGIC_PIRS;
+ }
+
+ static boolean isBLFFile(byte[] data) {
+ return data.length >= BLF_CHUNK_HEADER_SIZE && readInt32(data, 0) == BLF_START;
+ }
+
+ /**
+ * Lists the files inside a Halo 3 package. The Arbiter uses this to tell
+ * screenshots (screen.shot) from maps (sandbox.map) without trusting file names.
+ *
+ * @throws Exception if the package is damaged or belongs to another game
+ */
+ static List<String> listPackageFiles(byte[] data) throws Exception {
+ StfsPackage stfs = new StfsPackage(data);
+ if (stfs.titleId != HALO3_TITLE_ID) {
+ System.out.printf("Package belongs to title %08X, not Halo 3%n", stfs.titleId);
+ throw ErrorHandler.makeException(ErrorHandler.UNKNOWN_FILE + ": not a Halo 3 package");
+ }
+ List<String> names = new ArrayList<String>();
+ for (StfsEntry entry : stfs.entries) {
+ if (!entry.isDirectory) {
+ names.add(entry.name);
+ }
+ }
+ return names;
+ }
+
+ /**
+ * Saved game file type of an already extracted BLF file
+ * (FILE_TYPE_SCREENSHOT, FILE_TYPE_MAP_VARIANT...), or -1 if it can't be told.
+ */
+ static int readBlfFileType(byte[] blf) {
+ if (!isBLFFile(blf)) return -1;
+ int[] chunks = findBlfChunks(blf);
+ if (chunks == null) return -1;
+ if (hasMetadata(blf, chunks[0])) {
+ return readInt32(blf, chunks[0] + METADATA_OFFSET + METADATA_FILE_TYPE);
+ }
+ return chunks[1] >= 0 ? FILE_TYPE_MAP_VARIANT : -1;
+ }
+
+ /**
+ * Finds sandbox.map in the package and returns its contents.
+ */
+ private byte[] extractFromSTFS(byte[] data) throws Exception {
+ StfsPackage stfs = new StfsPackage(data);
+
+ System.out.printf("Package: \"%s\", title ID %08X%n", stfs.displayName, stfs.titleId);
+ if (stfs.titleId != HALO3_TITLE_ID) {
+ throw ErrorHandler.makeException(ErrorHandler.UNKNOWN_FILE + ": not a Halo 3 package");
+ }
+
+ StfsEntry mapEntry = null;
+ for (StfsEntry entry : stfs.entries) {
+ System.out.println("Package contains: " + entry.name
+ + (entry.isDirectory ? " (folder)" : " (" + entry.size + " bytes)"));
+ if (mapEntry == null && !entry.isDirectory && entry.name.equalsIgnoreCase(MAP_VARIANT_FILE)) {
+ mapEntry = entry;
+ }
+ }
+
+ if (mapEntry == null) {
+ // the "Package contains" lines above tell what is in there instead
+ throw ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": no Forge map inside");
+ }
+
+ byte[] mapData = stfs.readFile(mapEntry);
+
+ if (stfs.badBlocks > 0) {
+ System.out.println("Warning: " + stfs.badBlocks + " block(s) of " + MAP_VARIANT_FILE
+ + " do not match the package hashes, the package may be damaged");
+ } else {
+ System.out.println("Package hashes OK");
+ }
+ return mapData;
+ }
+
+ /**
+ * Walks the BLF chunks of a map variant, makes sure it really is a Forge map
+ * and reads its name, description and author.
+ */
+ private MapInfo readMapInfo(byte[] blf) throws Exception {
+ if (!isBLFFile(blf)) {
+ System.out.println(MAP_VARIANT_FILE + " is not a map variant (BLF) file");
+ throw ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": not a Forge map");
+ }
+
+ int[] chunks = findBlfChunks(blf);
+ if (chunks == null) {
+ System.out.println("Map variant has a broken chunk");
+ throw ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": damaged map variant");
+ }
+ int contentHeader = chunks[0];
+ int mapVariant = chunks[1];
+ if (chunks[2] < 0) {
+ System.out.println("Map variant is incomplete, its end of file chunk is missing");
+ throw ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": damaged map variant");
+ }
+
+ // both chdr and mvar start with the same metadata block, chdr is the official one
+ int metadata = -1;
+ if (hasMetadata(blf, contentHeader)) {
+ metadata = contentHeader + METADATA_OFFSET;
+ } else if (hasMetadata(blf, mapVariant)) {
+ metadata = mapVariant + METADATA_OFFSET;
+ }
+
+ int fileType = metadata >= 0 ? readInt32(blf, metadata + METADATA_FILE_TYPE) : -1;
+ if (mapVariant < 0 && fileType != FILE_TYPE_MAP_VARIANT) {
+ if (fileType >= 0 && fileType < FILE_TYPE_NAMES.length) {
+ System.out.println("This file is a " + FILE_TYPE_NAMES[fileType]);
+ }
+ throw ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": not a Forge map");
+ }
+
+ MapInfo info = new MapInfo();
+ if (metadata >= 0) {
+ info.name = readUtf16(blf, metadata + METADATA_NAME, 32);
+ info.description = readUtf8(blf, metadata + METADATA_DESCRIPTION, 128);
+ info.author = readUtf8(blf, metadata + METADATA_AUTHOR, 16);
+ info.mapId = readInt32(blf, metadata + METADATA_MAP_ID);
+ }
+ return info;
+ }
+
+ /**
+ * Walks the BLF chunks (4 byte signature, 4 byte size including the header,
+ * 2+2 byte version) and returns the offsets of the chdr chunk, the map variant
+ * chunk and the _eof chunk, -1 for the missing ones. Null if a chunk is broken.
+ */
+ private static int[] findBlfChunks(byte[] blf) {
+ int contentHeader = -1;
+ int mapVariant = -1;
+ int end = -1;
+
+ int offset = 0;
+ while (offset + BLF_CHUNK_HEADER_SIZE <= blf.length) {
+ int signature = readInt32(blf, offset);
+ int size = readInt32(blf, offset + 4);
+ if (size < BLF_CHUNK_HEADER_SIZE || size > blf.length - offset) {
+ return null;
+ }
+
+ if (signature == BLF_CONTENT_HEADER && contentHeader < 0) {
+ contentHeader = offset;
+ } else if ((signature == BLF_MAP_VARIANT || signature == BLF_MAP_VARIANT_PC) && mapVariant < 0) {
+ mapVariant = offset;
+ } else if (signature == BLF_END) {
+ end = offset;
+ break;
+ }
+ offset += size;
+ }
+ return new int[] { contentHeader, mapVariant, end };
+ }
+
+ /**
+ * True if there is a chunk at chunkOffset big enough to hold the metadata block.
+ */
+ private static boolean hasMetadata(byte[] blf, int chunkOffset) {
+ if (chunkOffset < 0) return false;
+ int chunkSize = readInt32(blf, chunkOffset + 4);
+ return chunkSize >= METADATA_OFFSET + METADATA_MAP_ID + 4;
+ }
+
+ /**
+ * What the map variant says about itself.
+ */
+ private static final class MapInfo {
+ String name = "";
+ String description = "";
+ String author = "";
+ int mapId = -1;
+ }
+
+ /**
+ * One entry of the STFS file table.
+ */
+ private static final class StfsEntry {
+ final String name;
+ final boolean isDirectory;
+ final boolean consecutive; // blocks follow each other, no need for the hash chain
+ final int blockCount;
+ final int startBlock;
+ final int size;
+
+ StfsEntry(byte[] data, int offset) {
+ int flags = data[offset + 0x28] & 0xFF;
+ int nameLength = Math.min(flags & 0x3F, 0x28);
+ int end = offset;
+ while (end < offset + nameLength && data[end] != 0) {
+ end++;
+ }
+ name = new String(data, offset, end - offset, StandardCharsets.ISO_8859_1);
+ isDirectory = (flags & 0x80) != 0;
+ consecutive = (flags & 0x40) != 0;
+ blockCount = readUInt24LE(data, offset + 0x29);
+ startBlock = readUInt24LE(data, offset + 0x2F);
+ size = readInt32(data, offset + 0x34);
+ }
+ }
+
+ /**
+ * Read only STFS reader, just enough to list a package and read files from it.
+ *
+ * <p>Data blocks are 4 KB. Every 170 data blocks there is a hash table
+ * (and above 170 blocks also a level 1 table). Packages made by the console
+ * keep two copies of each table and a status bit says which one is current.
+ * Block math follows Free60 and Velocity (hetelek/Velocity).</p>
+ */
+ private static final class StfsPackage {
+ final byte[] data;
+ final int titleId;
+ final String displayName;
+ final List<StfsEntry> entries = new ArrayList<StfsEntry>();
+ int badBlocks = 0;
+
+ private final int blockSeparation;
+ private final int tableShift; // 1 = two copies of every hash table, 0 = one copy
+ private final int allocatedBlocks;
+ private final long firstHashTable;
+ private final int topLevel;
+ private final long topTable;
+
+ StfsPackage(byte[] data) throws Exception {
+ this.data = data;
+
+ if (data.length < 0x3AD) {
+ throw damaged("header is cut off");
+ }
+ int headerSize = readInt32(data, 0x340);
+ if (headerSize < 0x3AD || headerSize > data.length) {
+ throw damaged("bad header size");
+ }
+ titleId = readInt32(data, 0x360);
+ displayName = readUtf16(data, 0x411, 0x80);
+
+ // volume descriptor at 0x379, file system type at 0x3A9 (0 = STFS)
+ if ((data[0x379] & 0xFF) != 0x24 || readInt32(data, 0x3A9) != 0) {
+ System.out.println("Package does not use the STFS file system");
+ throw ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": unsupported package type");
+ }
+ blockSeparation = data[0x37B] & 0xFF;
+ int fileTableBlockCount = readUInt16LE(data, 0x37C);
+ int fileTableBlock = readUInt24LE(data, 0x37E);
+ allocatedBlocks = readInt32(data, 0x395);
+
+ tableShift = (~blockSeparation) & 1;
+ firstHashTable = (headerSize + 0xFFFL) & ~0xFFFL;
+
+ if (allocatedBlocks <= 0) {
+ throw damaged("no data blocks");
+ } else if (allocatedBlocks <= STFS_BLOCKS_PER_TABLE) {
+ topLevel = 0;
+ } else if (allocatedBlocks <= STFS_BLOCKS_PER_LEVEL1) {
+ topLevel = 1;
+ } else {
+ System.out.println("Package has " + allocatedBlocks + " blocks, far more than a Forge map needs");
+ throw ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": file too big for a Forge map");
+ }
+
+ // the top table is the level 0 table for small packages, the level 1 table otherwise
+ long topTableBlock = (topLevel == 0) ? 0 : levelOneTableBlock();
+ topTable = firstHashTable + (topTableBlock << 12) + secondCopyOffset((blockSeparation & 2) != 0);
+
+ readFileTable(fileTableBlock, fileTableBlockCount);
+ }
+
+ /**
+ * Reads all file entries, following the block chain of the file table.
+ */
+ private void readFileTable(int block, int blockCount) throws Exception {
+ for (int i = 0; i < blockCount; i++) {
+ long base = blockOffset(checkBlock(block));
+ requireBytes(base, STFS_BLOCK_SIZE);
+
+ for (int slot = 0; slot < STFS_BLOCK_SIZE / STFS_FILE_ENTRY_SIZE; slot++) {
+ int entry = (int) base + slot * STFS_FILE_ENTRY_SIZE;
+ int flags = data[entry + 0x28] & 0xFF;
+ if ((flags & 0x3F) == 0) continue; // empty slot
+ if (data[entry] == 0) break; // end of the listing
+ entries.add(new StfsEntry(data, entry));
+ }
+
+ if (i + 1 < blockCount) {
+ block = nextBlock(block);
+ }
+ }
+ }
+
+ /**
+ * Returns the contents of a file in the package.
+ */
+ byte[] readFile(StfsEntry entry) throws Exception {
+ if (entry.isDirectory || entry.size < 0
+ || (long) entry.size > (long) entry.blockCount * STFS_BLOCK_SIZE) {
+ throw damaged("bad file table entry");
+ }
+
+ byte[] out = new byte[entry.size];
+ int block = entry.startBlock;
+ int copied = 0;
+ for (int i = 0; copied < entry.size; i++) {
+ if (i >= entry.blockCount) {
+ throw damaged("block chain is too short");
+ }
+ long offset = blockOffset(checkBlock(block));
+ int length = Math.min(STFS_BLOCK_SIZE, entry.size - copied);
+ requireBytes(offset, length);
+ System.arraycopy(data, (int) offset, out, copied, length);
+ verifyBlock(block, offset);
+
+ copied += length;
+ if (copied < entry.size) {
+ block = entry.consecutive ? block + 1 : nextBlock(block);
+ }
+ }
+ return out;
+ }
+
+ /**
+ * File offset of a data block: skips the header and every hash table in front of it.
+ * Valid below 0x70E4 blocks, bigger packages are rejected in the constructor.
+ */
+ private long blockOffset(int block) {
+ long backing = block + (((long) block + STFS_BLOCKS_PER_TABLE) / STFS_BLOCKS_PER_TABLE << tableShift);
+ if (block >= STFS_BLOCKS_PER_TABLE) {
+ backing += 1L << tableShift; // the level 1 table(s)
+ }
+ return firstHashTable + (backing << 12);
+ }
+
+ /**
+ * Block number (counting hash tables) of the level 0 hash table covering a data block.
+ */
+ private long levelZeroTableBlock(int block) {
+ if (block < STFS_BLOCKS_PER_TABLE) return 0;
+ long groupSize = STFS_BLOCKS_PER_TABLE + (1L << tableShift);
+ return (block / STFS_BLOCKS_PER_TABLE) * groupSize + (1L << tableShift);
+ }
+
+ /**
+ * Block number (counting hash tables) of the level 1 hash table.
+ */
+ private long levelOneTableBlock() {
+ return STFS_BLOCKS_PER_TABLE + (1L << tableShift);
+ }
+
+ private long secondCopyOffset(boolean useSecondCopy) {
+ return (tableShift == 1 && useSecondCopy) ? STFS_BLOCK_SIZE : 0;
+ }
+
+ /**
+ * File offset of the hash entry that describes a data block.
+ */
+ private long hashEntryOffset(int block) throws Exception {
+ boolean secondCopy;
+ if (topLevel == 0) {
+ secondCopy = (blockSeparation & 2) != 0;
+ } else {
+ // the level 1 entry of this group says which level 0 copy is current
+ secondCopy = (readByte(topTable + (long) (block / STFS_BLOCKS_PER_TABLE) * STFS_HASH_ENTRY_SIZE + 0x14) & 0x40) != 0;
+ }
+ return firstHashTable + (levelZeroTableBlock(block) << 12) + secondCopyOffset(secondCopy)
+ + (long) (block % STFS_BLOCKS_PER_TABLE) * STFS_HASH_ENTRY_SIZE;
+ }
+
+ /**
+ * Next block of a chain, as written in the hash entry (24 bit, big endian).
+ */
+ private int nextBlock(int block) throws Exception {
+ long entry = hashEntryOffset(block);
+ return (readByte(entry + 0x15) << 16) | (readByte(entry + 0x16) << 8) | readByte(entry + 0x17);
+ }
+
+ /**
+ * Compares the SHA-1 of a data block with the one stored in the hash table.
+ */
+ private void verifyBlock(int block, long offset) throws Exception {
+ long entry = hashEntryOffset(block);
+ if (offset + STFS_BLOCK_SIZE > data.length || entry + 0x14 > data.length) {
+ badBlocks++;
+ return;
+ }
+ MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
+ sha1.update(data, (int) offset, STFS_BLOCK_SIZE);
+ byte[] expected = Arrays.copyOfRange(data, (int) entry, (int) entry + 0x14);
+ if (!Arrays.equals(sha1.digest(), expected)) {
+ badBlocks++;
+ }
+ }
+
+ private int checkBlock(int block) throws Exception {
+ if (block < 0 || block >= allocatedBlocks) {
+ throw damaged("block chain points outside the package");
+ }
+ return block;
+ }
+
+ private void requireBytes(long offset, int length) throws Exception {
+ if (offset < 0 || offset + length > data.length) {
+ throw damaged("file is cut off");
+ }
+ }
+
+ private int readByte(long offset) throws Exception {
+ requireBytes(offset, 1);
+ return data[(int) offset] & 0xFF;
+ }
+
+ // short message for the status bar, the reason goes to the console
+ private static Exception damaged(String reason) {
+ System.out.println("Damaged package: " + reason);
+ return ErrorHandler.makeException(ErrorHandler.CANT_PROCESS + ": damaged package");
+ }
+ }
+
+ // helpers, Xbox 360 data is big endian except a few STFS fields
+
+ private static int readInt32(byte[] data, int offset) {
+ return ((data[offset] & 0xFF) << 24) | ((data[offset + 1] & 0xFF) << 16)
+ | ((data[offset + 2] & 0xFF) << 8) | (data[offset + 3] & 0xFF);
+ }
+
+ private static int readUInt16LE(byte[] data, int offset) {
+ return (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8);
+ }
+
+ private static int readUInt24LE(byte[] data, int offset) {
+ return (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8) | ((data[offset + 2] & 0xFF) << 16);
+ }
+
+ /**
+ * Reads a zero terminated UTF-16 (big endian) string of at most maxBytes bytes.
+ */
+ private static String readUtf16(byte[] data, int offset, int maxBytes) {
+ if (offset >= data.length) return "";
+ int limit = Math.min(data.length, offset + maxBytes);
+ int end = offset;
+ while (end + 1 < limit && (data[end] != 0 || data[end + 1] != 0)) {
+ end += 2;
+ }
+ return clean(new String(data, offset, end - offset, StandardCharsets.UTF_16BE));
+ }
+
+ /**
+ * Reads a zero terminated UTF-8 string of at most maxBytes bytes.
+ */
+ private static String readUtf8(byte[] data, int offset, int maxBytes) {
+ if (offset >= data.length) return "";
+ int limit = Math.min(data.length, offset + maxBytes);
+ int end = offset;
+ while (end < limit && data[end] != 0) {
+ end++;
+ }
+ return clean(new String(data, offset, end - offset, StandardCharsets.UTF_8));
+ }
+
+ // drop control characters so a damaged name can't mess up the output
+ private static String clean(String text) {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ sb.append(Character.isISOControl(c) ? ' ' : c);
+ }
+ return sb.toString().trim();
}
-}
\ No newline at end of file
+}
/*
* Halo 3 Reclaimer [TerminalUI.java]
* player data extractor from Xbox 360 version of Halo 3
-* copyright 2025, David Polakovic, licensed under GPLv3
+* copyright 2025, 2026, David Polakovic, licensed under GPLv3
* contact: email@dpolakovic.space
*/
*
* <p>This class contains all the command-line interface logic.</p>
*
-* @version 1.0
+* @version 1.1
*/
public class TerminalUI {
}
try {
- System.out.println("Processing file: " + inputFile.getName());
-
- String fileType = Arbiter.getFileTypeDescription(inputFile);
- if (!fileType.equals("Unrecognized file")) {
- System.out.println("Detected as: " + fileType);
- }
-
- // use Arbiter to process the file
- Arbiter.processFile(inputFile, outputDir);
-
- String baseName = inputFile.getName();
- int dotIndex = baseName.lastIndexOf('.');
- if (dotIndex > 0) {
- baseName = baseName.substring(0, dotIndex);
- }
- String outputFileName = baseName + "-reclaimed.jpg";
- File outputFile = new File(outputDir, outputFileName);
-
- System.out.println("File successfully processed to: " + outputFile.getAbsolutePath());
-
+ System.out.println((inputFile.isDirectory() ? "Processing folder: " : "Processing file: ")
+ + inputFile.getName());
+
+ // use Arbiter to process the file or folder
+ String result = Arbiter.processFile(inputFile, outputDir);
+
+ System.out.println(result + " into: " + outputDir.getAbsolutePath());
+
} catch (Exception e) {
// parse error code from exception message if it starts with a number
String message = e.getMessage();
public static void showTooManyArguments() {
ErrorHandler.exitWithError(ErrorHandler.TOO_MANY_ARGS);
}
-}
\ No newline at end of file
+}